├── coverage ├── .resultset.json.lock ├── .last_run.json ├── assets │ ├── 0.12.2 │ │ ├── loading.gif │ │ ├── magnify.png │ │ ├── favicon_green.png │ │ ├── favicon_red.png │ │ ├── colorbox │ │ │ ├── border.png │ │ │ ├── controls.png │ │ │ ├── loading.gif │ │ │ └── loading_background.png │ │ ├── favicon_yellow.png │ │ ├── images │ │ │ ├── ui-icons_222222_256x240.png │ │ │ ├── ui-icons_2e83ff_256x240.png │ │ │ ├── ui-icons_454545_256x240.png │ │ │ ├── ui-icons_888888_256x240.png │ │ │ ├── ui-icons_cd0a0a_256x240.png │ │ │ ├── ui-bg_flat_0_aaaaaa_40x100.png │ │ │ ├── ui-bg_flat_75_ffffff_40x100.png │ │ │ ├── ui-bg_glass_55_fbf9ee_1x400.png │ │ │ ├── ui-bg_glass_65_ffffff_1x400.png │ │ │ ├── ui-bg_glass_75_dadada_1x400.png │ │ │ ├── ui-bg_glass_75_e6e6e6_1x400.png │ │ │ ├── ui-bg_glass_95_fef1ec_1x400.png │ │ │ └── ui-bg_highlight-soft_75_cccccc_1x100.png │ │ └── DataTables-1.10.20 │ │ │ └── images │ │ │ ├── sort_asc.png │ │ │ ├── sort_both.png │ │ │ ├── sort_desc.png │ │ │ ├── sort_asc_disabled.png │ │ │ └── sort_desc_disabled.png │ └── 0.12.3 │ │ ├── loading.gif │ │ ├── magnify.png │ │ ├── favicon_green.png │ │ ├── favicon_red.png │ │ ├── colorbox │ │ ├── border.png │ │ ├── controls.png │ │ ├── loading.gif │ │ └── loading_background.png │ │ ├── favicon_yellow.png │ │ ├── images │ │ ├── ui-icons_222222_256x240.png │ │ ├── ui-icons_2e83ff_256x240.png │ │ ├── ui-icons_454545_256x240.png │ │ ├── ui-icons_888888_256x240.png │ │ ├── ui-icons_cd0a0a_256x240.png │ │ ├── ui-bg_flat_0_aaaaaa_40x100.png │ │ ├── ui-bg_flat_75_ffffff_40x100.png │ │ ├── ui-bg_glass_55_fbf9ee_1x400.png │ │ ├── ui-bg_glass_65_ffffff_1x400.png │ │ ├── ui-bg_glass_75_dadada_1x400.png │ │ ├── ui-bg_glass_75_e6e6e6_1x400.png │ │ ├── ui-bg_glass_95_fef1ec_1x400.png │ │ └── ui-bg_highlight-soft_75_cccccc_1x100.png │ │ └── DataTables-1.10.20 │ │ └── images │ │ ├── sort_asc.png │ │ ├── sort_both.png │ │ ├── sort_desc.png │ │ ├── sort_asc_disabled.png │ │ └── sort_desc_disabled.png └── .resultset.json ├── .gitignore ├── assets ├── javascripts │ └── discourse │ │ ├── templates │ │ └── components │ │ │ ├── user-vote-count.hbs │ │ │ └── qa-topic-tip.hbs │ │ ├── connectors │ │ ├── user-post-names │ │ │ └── vote-count.hbs │ │ ├── user-card-post-names │ │ │ └── vote-count.hbs │ │ ├── category-custom-settings │ │ │ ├── enable-qa.js.es6 │ │ │ └── enable-qa.hbs │ │ ├── after-topic-footer-main-buttons │ │ │ ├── answer-button-wrapper.hbs │ │ │ └── answer-button-wrapper.js.es6 │ │ └── topic-title │ │ │ ├── qa-tip-container.hbs │ │ │ └── qa-tip-container.js.es6 │ │ ├── components │ │ ├── user-vote-count.js.es6 │ │ └── qa-topic-tip.js.es6 │ │ ├── widgets │ │ ├── qa-button.js.es6 │ │ └── qa-post.js.es6 │ │ ├── lib │ │ └── qa-utilities.js.es6 │ │ └── initializers │ │ └── qa-edits.js.es6 └── stylesheets │ ├── mobile │ └── question-answer.scss │ ├── desktop │ └── question-answer.scss │ └── common │ └── question-answer.scss ├── lib └── question_answer │ ├── voter.rb │ ├── engine.rb │ └── vote.rb ├── .travis.yml ├── extensions ├── topic_view_extension.rb ├── post_creator_extension.rb ├── post_action_type_extension.rb ├── category_custom_field_extension.rb ├── category_extension.rb ├── guardian_extension.rb ├── topic_tag_extension.rb ├── topic_list_item_serializer_extension.rb ├── post_extension.rb ├── topic_view_serializer_extension.rb ├── post_serializer_extension.rb └── topic_extension.rb ├── app ├── serializers │ └── question_answer │ │ └── voter_serializer.rb └── controllers │ └── question_answer │ └── votes_controller.rb ├── config ├── routes.rb ├── locales │ ├── server.it.yml │ ├── server.zh_CN.yml │ ├── server.fi.yml │ ├── server.fr.yml │ ├── server.es.yml │ ├── client.it.yml │ ├── server.he.yml │ ├── server.en.yml │ ├── client.zh_CN.yml │ ├── client.fr.yml │ ├── server.de.yml │ ├── client.es.yml │ ├── client.fi.yml │ ├── client.he.yml │ ├── client.en.yml │ └── client.de.yml └── settings.yml ├── spec ├── plugin_helper.rb ├── components │ └── question_answer │ │ ├── post_action_type_spec.rb │ │ ├── post_creator_spec.rb │ │ ├── topic_tag_spec.rb │ │ ├── guardian_spec.rb │ │ ├── category_spec.rb │ │ ├── vote_spec.rb │ │ ├── post_spec.rb │ │ └── topic_spec.rb ├── jobs │ ├── update_topic_post_order_spec.rb │ ├── update_category_post_order_spec.rb │ └── qa_update_topics_post_order_spec.rb ├── serializers │ └── question_answer │ │ ├── topic_list_item_serializer_spec.rb │ │ ├── topic_view_serializer_spec.rb │ │ └── post_serializer_spec.rb └── requests │ └── question_answer │ └── votes_controller_spec.rb ├── .tx └── config ├── jobs ├── qa_update_topics_post_order.rb ├── update_topic_post_order.rb └── update_category_post_order.rb ├── test └── javascripts │ └── acceptance │ └── question_answer │ ├── post-stream.js.es6 │ ├── composer.js.es6 │ └── post.js.es6 ├── COPYRIGHT.txt ├── db └── migrate │ └── 20180712004204_save_existing_post_vote_counts_to_custom_fields.rb ├── README.md ├── plugin.rb └── LICENSE.txt /coverage/.resultset.json.lock: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage/* 2 | !coverage/.last_run.json -------------------------------------------------------------------------------- /coverage/.last_run.json: -------------------------------------------------------------------------------- 1 | { 2 | "result": { 3 | "covered_percent": 97.01 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/templates/components/user-vote-count.hbs: -------------------------------------------------------------------------------- 1 | {{{i18n "user_vote_count" voteCount=voteCount}}} 2 | -------------------------------------------------------------------------------- /lib/question_answer/voter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module QuestionAnswer 4 | Voter = Struct.new(:user) 5 | end 6 | -------------------------------------------------------------------------------- /coverage/assets/0.12.2/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/loading.gif -------------------------------------------------------------------------------- /coverage/assets/0.12.2/magnify.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/magnify.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/loading.gif -------------------------------------------------------------------------------- /coverage/assets/0.12.3/magnify.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/magnify.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/favicon_green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/favicon_green.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/favicon_red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/favicon_red.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/favicon_green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/favicon_green.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/favicon_red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/favicon_red.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/colorbox/border.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/colorbox/border.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/favicon_yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/favicon_yellow.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/colorbox/border.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/colorbox/border.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/favicon_yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/favicon_yellow.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/colorbox/controls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/colorbox/controls.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/colorbox/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/colorbox/loading.gif -------------------------------------------------------------------------------- /coverage/assets/0.12.3/colorbox/controls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/colorbox/controls.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/colorbox/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/colorbox/loading.gif -------------------------------------------------------------------------------- /assets/javascripts/discourse/connectors/user-post-names/vote-count.hbs: -------------------------------------------------------------------------------- 1 | {{#if siteSettings.qa_enabled}} 2 | {{user-vote-count voteCount=args.model.vote_count}} 3 | {{/if}} 4 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/connectors/user-card-post-names/vote-count.hbs: -------------------------------------------------------------------------------- 1 | {{#if siteSettings.qa_enabled}} 2 | {{user-vote-count voteCount=args.user.vote_count}} 3 | {{/if}} 4 | -------------------------------------------------------------------------------- /coverage/assets/0.12.2/colorbox/loading_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/colorbox/loading_background.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/colorbox/loading_background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/colorbox/loading_background.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/images/ui-icons_222222_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/images/ui-icons_222222_256x240.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/images/ui-icons_2e83ff_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/images/ui-icons_2e83ff_256x240.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/images/ui-icons_454545_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/images/ui-icons_454545_256x240.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/images/ui-icons_888888_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/images/ui-icons_888888_256x240.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/images/ui-icons_cd0a0a_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/images/ui-icons_cd0a0a_256x240.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/images/ui-icons_222222_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/images/ui-icons_222222_256x240.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/images/ui-icons_2e83ff_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/images/ui-icons_2e83ff_256x240.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/images/ui-icons_454545_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/images/ui-icons_454545_256x240.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/images/ui-icons_888888_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/images/ui-icons_888888_256x240.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/images/ui-icons_cd0a0a_256x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/images/ui-icons_cd0a0a_256x240.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/images/ui-bg_flat_0_aaaaaa_40x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/images/ui-bg_flat_0_aaaaaa_40x100.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/images/ui-bg_flat_0_aaaaaa_40x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/images/ui-bg_flat_0_aaaaaa_40x100.png -------------------------------------------------------------------------------- /lib/question_answer/engine.rb: -------------------------------------------------------------------------------- 1 | module ::QuestionAnswer 2 | class Engine < Rails::Engine 3 | engine_name 'question_answer' 4 | isolate_namespace QuestionAnswer 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/user-vote-count.js.es6: -------------------------------------------------------------------------------- 1 | import Component from "@ember/component"; 2 | 3 | export default Component.extend({ 4 | classNames: ["user-vote-count"] 5 | }); 6 | -------------------------------------------------------------------------------- /coverage/assets/0.12.2/DataTables-1.10.20/images/sort_asc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/DataTables-1.10.20/images/sort_asc.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/DataTables-1.10.20/images/sort_both.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/DataTables-1.10.20/images/sort_both.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/DataTables-1.10.20/images/sort_desc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/DataTables-1.10.20/images/sort_desc.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/images/ui-bg_flat_75_ffffff_40x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/images/ui-bg_flat_75_ffffff_40x100.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/images/ui-bg_glass_55_fbf9ee_1x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/images/ui-bg_glass_55_fbf9ee_1x400.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/images/ui-bg_glass_65_ffffff_1x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/images/ui-bg_glass_65_ffffff_1x400.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/images/ui-bg_glass_75_dadada_1x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/images/ui-bg_glass_75_dadada_1x400.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/images/ui-bg_glass_75_e6e6e6_1x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/images/ui-bg_glass_75_e6e6e6_1x400.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/images/ui-bg_glass_95_fef1ec_1x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/images/ui-bg_glass_95_fef1ec_1x400.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/DataTables-1.10.20/images/sort_asc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/DataTables-1.10.20/images/sort_asc.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/DataTables-1.10.20/images/sort_both.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/DataTables-1.10.20/images/sort_both.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/DataTables-1.10.20/images/sort_desc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/DataTables-1.10.20/images/sort_desc.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/images/ui-bg_flat_75_ffffff_40x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/images/ui-bg_flat_75_ffffff_40x100.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/images/ui-bg_glass_55_fbf9ee_1x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/images/ui-bg_glass_55_fbf9ee_1x400.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/images/ui-bg_glass_65_ffffff_1x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/images/ui-bg_glass_65_ffffff_1x400.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/images/ui-bg_glass_75_dadada_1x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/images/ui-bg_glass_75_dadada_1x400.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/images/ui-bg_glass_75_e6e6e6_1x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/images/ui-bg_glass_75_e6e6e6_1x400.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/images/ui-bg_glass_95_fef1ec_1x400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/images/ui-bg_glass_95_fef1ec_1x400.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/DataTables-1.10.20/images/sort_asc_disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/DataTables-1.10.20/images/sort_asc_disabled.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/images/ui-bg_highlight-soft_75_cccccc_1x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/images/ui-bg_highlight-soft_75_cccccc_1x100.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/DataTables-1.10.20/images/sort_asc_disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/DataTables-1.10.20/images/sort_asc_disabled.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/images/ui-bg_highlight-soft_75_cccccc_1x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/images/ui-bg_highlight-soft_75_cccccc_1x100.png -------------------------------------------------------------------------------- /coverage/assets/0.12.2/DataTables-1.10.20/images/sort_desc_disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.2/DataTables-1.10.20/images/sort_desc_disabled.png -------------------------------------------------------------------------------- /coverage/assets/0.12.3/DataTables-1.10.20/images/sort_desc_disabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paviliondev/discourse-question-answer/HEAD/coverage/assets/0.12.3/DataTables-1.10.20/images/sort_desc_disabled.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | services: 2 | - docker 3 | 4 | before_install: 5 | - git clone --depth=1 https://github.com/discourse/discourse-plugin-ci 6 | 7 | install: true 8 | 9 | script: 10 | - discourse-plugin-ci/script.sh 11 | -------------------------------------------------------------------------------- /extensions/topic_view_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module QuestionAnswer 4 | module TopicViewExtension 5 | def qa_enabled 6 | Topic.qa_enabled(@topic) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /app/serializers/question_answer/voter_serializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module QuestionAnswer 4 | class VoterSerializer < ::PostActionUserSerializer 5 | def post_url 6 | nil 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/connectors/category-custom-settings/enable-qa.js.es6: -------------------------------------------------------------------------------- 1 | export default { 2 | setupComponent(attrs) { 3 | if (!attrs.category.custom_fields) { 4 | attrs.category.custom_fields = {}; 5 | } 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /extensions/post_creator_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module QuestionAnswer 4 | module PostCreatorExtension 5 | def valid? 6 | guardian.post_opts = @opts 7 | super 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /extensions/post_action_type_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module QuestionAnswer 4 | module PostActionTypeExtension 5 | def public_types 6 | @public_types ||= super.except(:vote) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/connectors/after-topic-footer-main-buttons/answer-button-wrapper.hbs: -------------------------------------------------------------------------------- 1 | {{#if showCreateAnswer}} 2 | {{d-button 3 | class="btn-primary create answer" 4 | icon="reply" 5 | action="answerQuestion" 6 | label=label 7 | title=title 8 | }} 9 | {{/if}} 10 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/templates/components/qa-topic-tip.hbs: -------------------------------------------------------------------------------- 1 | {{d-button 2 | class="btn btn-topic-tip" 3 | action=(action "toggleDetails") 4 | label=label 5 | icon="info" 6 | }} 7 | 8 | {{#if showDetails}} 9 |
10 | {{cookedDetails}} 11 |
12 | {{/if}} 13 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | QuestionAnswer::Engine.routes.draw do 4 | resource :vote 5 | get 'voters' => 'votes#voters' 6 | post 'set_as_answer' => 'votes#set_as_answer' 7 | end 8 | 9 | Discourse::Application.routes.append do 10 | mount ::QuestionAnswer::Engine, at: 'qa' 11 | end 12 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/connectors/topic-title/qa-tip-container.hbs: -------------------------------------------------------------------------------- 1 | {{#if model.qa_enabled}} 2 | {{d-section 3 | pageClass="topic-qa-enabled" 4 | scrollTop=false 5 | }} 6 | {{/if}} 7 | 8 | {{#if showTip}} 9 | {{qa-topic-tip 10 | label=label 11 | details=details 12 | detailsOpts=detailsOpts 13 | }} 14 | {{/if}} 15 | -------------------------------------------------------------------------------- /spec/plugin_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | if ENV['SIMPLECOV'] 4 | require 'simplecov' 5 | 6 | SimpleCov.start do 7 | root "plugins/discourse-question-answer" 8 | track_files "plugins/discourse-question-answer/**/*.rb" 9 | add_filter { |src| src.filename =~ /(\/spec\/|\/db\/|plugin\.rb)/ } 10 | end 11 | end 12 | 13 | require 'rails_helper' -------------------------------------------------------------------------------- /.tx/config: -------------------------------------------------------------------------------- 1 | [main] 2 | host = https://www.transifex.com 3 | 4 | [discourse-question-answer.client-en-yml] 5 | file_filter = config/locales/client..yml 6 | minimum_perc = 0 7 | source_file = config/locales/client.en.yml 8 | source_lang = en 9 | type = YML 10 | 11 | [discourse-question-answer.server-en-yml] 12 | file_filter = config/locales/server..yml 13 | source_file = config/locales/server.en.yml 14 | source_lang = en 15 | type = YML 16 | 17 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/widgets/qa-button.js.es6: -------------------------------------------------------------------------------- 1 | import { createWidget } from "discourse/widgets/widget"; 2 | import { iconNode } from "discourse-common/lib/icon-library"; 3 | 4 | export default createWidget("qa-button", { 5 | tagName: "button.btn.qa-button", 6 | 7 | html(attrs) { 8 | return iconNode(`angle-${attrs.direction}`); 9 | }, 10 | 11 | click() { 12 | this.sendWidgetAction("vote", this.attrs.direction); 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /spec/components/question_answer/post_action_type_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../plugin_helper' 4 | 5 | describe QuestionAnswer::PostActionTypeExtension do 6 | it 'should recognize vote action' do 7 | expect(PostActionType.types[:vote]).to eq(100) 8 | end 9 | 10 | it 'should exclude vote from public_types' do 11 | expect(PostActionType.public_types.include?(:vote)).to eq(false) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /extensions/category_custom_field_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module QuestionAnswer 4 | module CategoryCustomFieldExtension 5 | def self.included(base) 6 | base.after_commit :update_post_order, if: :qa_enabled_changed 7 | end 8 | 9 | def qa_enabled_changed 10 | name == 'qa_enabled' 11 | end 12 | 13 | def update_post_order 14 | Jobs.enqueue(:update_category_post_order, category_id: category_id) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /jobs/qa_update_topics_post_order.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jobs 4 | class QaUpdateTopicsPostOrder < ::Jobs::Onceoff 5 | def execute_onceoff(_args) 6 | Topic.find_each do |topic| 7 | if topic.qa_enabled 8 | Topic.qa_update_vote_order(topic.id) 9 | else 10 | topic.posts.each do |post| 11 | post.update_columns(sort_order: post.post_number) 12 | end 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /jobs/update_topic_post_order.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jobs 4 | class UpdateTopicPostOrder < ::Jobs::Base 5 | def execute(args) 6 | topic = Topic.find_by(id: args[:topic_id]) 7 | 8 | return if topic.blank? 9 | 10 | if topic.qa_enabled 11 | Topic.qa_update_vote_order(topic.id) 12 | else 13 | topic.posts.each do |post| 14 | post.update_columns(sort_order: post.post_number) 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /extensions/category_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module QuestionAnswer 4 | module CategoryExtension 5 | def qa_cast(key) 6 | ActiveModel::Type::Boolean.new.cast(custom_fields[key]) || false 7 | end 8 | 9 | %w[ 10 | qa_enabled 11 | qa_one_to_many 12 | qa_disable_like_on_answers 13 | qa_disable_like_on_questions 14 | qa_disable_like_on_comments 15 | ].each do |key| 16 | define_method(key.to_sym) { qa_cast(key) } 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/components/question_answer/post_creator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../plugin_helper' 4 | 5 | describe QuestionAnswer::PostCreatorExtension do 6 | fab!(:user) { Fabricate(:user) } 7 | 8 | it 'should assign post_opts to guardian' do 9 | test_string = 'Test string' 10 | opts = { raw: test_string } 11 | post_creator = PostCreator.new(user, opts) 12 | 13 | post_creator.valid? 14 | 15 | expect(post_creator.guardian.post_opts[:raw]).to eq(test_string) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /extensions/guardian_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module QuestionAnswer 4 | module GuardianExtension 5 | def can_create_post_on_topic?(topic) 6 | post = self.try(:post_opts) || {} 7 | category = topic.category 8 | 9 | if category.present? && 10 | category.qa_enabled && 11 | category.qa_one_to_many && 12 | post.present? && 13 | !post[:reply_to_post_number] 14 | 15 | return @user.id == topic.user_id 16 | end 17 | 18 | super(topic) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /extensions/topic_tag_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module QuestionAnswer 4 | module TopicTagExtension 5 | def self.included(base) 6 | base.after_destroy :update_post_order, if: :qa_tag? 7 | end 8 | 9 | def qa_tag? 10 | if tag = Tag.find_by(id: tag_id) 11 | !([tag.name] & SiteSetting.qa_tags.split('|')).empty? 12 | else 13 | false 14 | end 15 | end 16 | 17 | def update_post_order 18 | Jobs.enqueue(:update_topic_post_order, topic_id: topic_id) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/javascripts/acceptance/question_answer/post-stream.js.es6: -------------------------------------------------------------------------------- 1 | import createStore from "helpers/create-store"; 2 | 3 | QUnit.module("model:post-stream"); 4 | 5 | const buildStream = function(id, stream) { 6 | const store = createStore(); 7 | const topic = store.createRecord("topic", { id, chunk_size: 5 }); 8 | const ps = topic.get("postStream"); 9 | if (stream) { 10 | ps.set("stream", stream); 11 | } 12 | return ps; 13 | }; 14 | 15 | QUnit.test("appending posts", assert => { 16 | 17 | }); 18 | 19 | QUnit.test("pre-pending posts", assert => { 20 | 21 | }); -------------------------------------------------------------------------------- /extensions/topic_list_item_serializer_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module QuestionAnswer 4 | module TopicListItemSerializerExtension 5 | def self.included(base) 6 | base.attributes :qa_enabled, 7 | :answer_count 8 | end 9 | 10 | def qa_enabled 11 | true 12 | end 13 | 14 | def include_qa_enabled? 15 | Topic.qa_enabled object 16 | end 17 | 18 | def answer_count 19 | object.answer_count 20 | end 21 | 22 | def include_answer_count? 23 | include_qa_enabled? 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/javascripts/acceptance/question_answer/composer.js.es6: -------------------------------------------------------------------------------- 1 | import { acceptance } from "helpers/qunit-helpers"; 2 | 3 | acceptance("Question Answer Composer", { 4 | loggedIn: true, 5 | pretend(server, helper) { 6 | server.get("/draft.json", () => { 7 | return helper.response({ 8 | draft: null, 9 | draft_sequence: 42 10 | }); 11 | }); 12 | server.post("/uploads/lookup-urls", () => { 13 | return helper.response([]); 14 | }); 15 | }, 16 | settings: { 17 | enable_whispers: true 18 | } 19 | }); 20 | 21 | QUnit.test("Composer Actions header content", async assert => { 22 | 23 | }); -------------------------------------------------------------------------------- /config/locales/server.it.yml: -------------------------------------------------------------------------------- 1 | it: 2 | site_settings: 3 | qa_tags: "Etichette per attivare Q&A sugli argomenti" 4 | qa_show_topic_tip: "Mostra un suggerimento sulla meccanica degli argomenti Q&A sotto i titoli degli argomenti Q&A" 5 | qa_enabled: "" 6 | qa_disable_like_on_answers: "" 7 | qa_undo_vote_action_window: "" 8 | qa_comments_default: "" 9 | 10 | post_action_types: 11 | vote: 12 | title: '' 13 | description: '' 14 | short_description: '' 15 | long_form: '' 16 | 17 | vote: 18 | error: 19 | user_has_not_voted: "" 20 | already_voted: "" 21 | undo_vote_action_window: "" 22 | -------------------------------------------------------------------------------- /jobs/update_category_post_order.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Jobs 4 | class UpdateCategoryPostOrder < ::Jobs::Base 5 | def execute(args) 6 | category = Category.find_by(id: args[:category_id]) 7 | 8 | return if category.blank? 9 | 10 | qa_enabled = category.qa_enabled 11 | 12 | Topic.where(category_id: category.id).each do |topic| 13 | if qa_enabled 14 | Topic.qa_update_vote_order(topic.id) 15 | else 16 | topic.posts.each do |post| 17 | post.update_columns(sort_order: post.post_number) 18 | end 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /config/locales/server.zh_CN.yml: -------------------------------------------------------------------------------- 1 | zh_CN: 2 | site_settings: 3 | qa_tags: "可以启用问答功能的标签" 4 | qa_show_topic_tip: "在问答话题的标题下显示关于问答机制的提示" 5 | qa_enabled: "启用问答插件" 6 | qa_disable_like_on_answers: "在问答贴中禁用“喜欢”按钮" 7 | qa_undo_vote_action_window: "允许用户撤销投票的分钟数(0 为不限制)" 8 | qa_comments_default: "默认显示评论的最大数量" 9 | 10 | post_action_types: 11 | vote: 12 | title: '投票' 13 | description: '为这个帖子投票' 14 | short_description: '为这个帖子投票' 15 | long_form: '为这个帖子投了票' 16 | 17 | vote: 18 | error: 19 | user_has_not_voted: "用户没有投票" 20 | already_voted: "你可以为每一个问题投一次票" 21 | undo_vote_action_window: "你可以在投票后的 %{minutes} 分钟后撤销投票" 22 | -------------------------------------------------------------------------------- /assets/stylesheets/mobile/question-answer.scss: -------------------------------------------------------------------------------- 1 | .topic-post.comment { 2 | padding-left: calc(5% + 30px); 3 | 4 | .topic-body { 5 | .topic-meta-data { 6 | margin-left: 30px; 7 | } 8 | 9 | .regular { 10 | margin-top: 0; 11 | } 12 | 13 | .post-menu-area { 14 | margin-top: 5px; 15 | margin-bottom: 0; 16 | } 17 | } 18 | } 19 | 20 | .qa-post { 21 | padding-right: 4%; 22 | } 23 | 24 | .qa-post + article { 25 | overflow: hidden; 26 | } 27 | 28 | .time-gap + .topic-post.answer, .time-gap + .topic-post.comment { 29 | .topic-body, .topic-avatar { 30 | padding-top: 15px; 31 | } 32 | 33 | .qa-post { 34 | margin-top: 15px; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /config/locales/server.fi.yml: -------------------------------------------------------------------------------- 1 | fi: 2 | site_settings: 3 | qa_tags: "" 4 | qa_show_topic_tip: "" 5 | qa_enabled: "" 6 | qa_disable_like_on_answers: "" 7 | qa_undo_vote_action_window: "" 8 | qa_comments_default: "" 9 | 10 | post_action_types: 11 | vote: 12 | title: 'Äänestä' 13 | description: 'Äänestä tätä parhaaksi vastaukseksi' 14 | short_description: 'Äänestä tätä parhaaksi vastaukseksi' 15 | long_form: 'äänesti tätä parhaaksi vastauseksi' 16 | 17 | vote: 18 | error: 19 | user_has_not_voted: "Käyttäjä ei ole äänestänyt." 20 | already_voted: "Voi äänestää vain yhtä vastausta tähän kysymykseen." 21 | undo_vote_action_window: "Sinulla on %{minutes} minuuttia aikaa perua annettu ääni. " 22 | -------------------------------------------------------------------------------- /test/javascripts/acceptance/question_answer/post.js.es6: -------------------------------------------------------------------------------- 1 | import EmberObject from "@ember/object"; 2 | import { moduleForWidget, widgetTest } from "helpers/widget-test"; 3 | 4 | moduleForWidget("post"); 5 | 6 | widgetTest("vote elements", { 7 | 8 | }); 9 | 10 | widgetTest("vote button", { 11 | 12 | }); 13 | 14 | widgetTest("undo vote button", { 15 | 16 | }); 17 | 18 | widgetTest("toggle voters button", { 19 | 20 | }); 21 | 22 | widgetTest("voters list", { 23 | 24 | }); 25 | 26 | widgetTest("show comments button", { 27 | 28 | }); 29 | 30 | widgetTest("disable likes", { 31 | 32 | }); 33 | 34 | widgetTest("answer button", { 35 | 36 | }); 37 | 38 | widgetTest("comment button", { 39 | 40 | }); 41 | 42 | widgetTest("question answer topic map", { 43 | 44 | }); -------------------------------------------------------------------------------- /COPYRIGHT.txt: -------------------------------------------------------------------------------- 1 | All code in this repository is Copyright 2018 by Angus McLeod. 2 | 3 | This program is free software; you can redistribute it and/or modify 4 | it under the terms of the GNU General Public License as published by 5 | the Free Software Foundation; either version 2 of the License, or (at 6 | your option) any later version. 7 | 8 | This program is distributed in the hope that it will be useful, but 9 | WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 10 | or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License 11 | for more details. 12 | 13 | You should have received a copy of the GNU General Public License 14 | along with this program as the file LICENSE.txt; if not, please see 15 | http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt. 16 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/connectors/topic-title/qa-tip-container.js.es6: -------------------------------------------------------------------------------- 1 | export default { 2 | setupComponent(attrs, component) { 3 | const oneToMany = 4 | attrs.model.category && attrs.model.category.qa_one_to_many; 5 | const siteSettings = attrs.model.siteSettings; 6 | const showTip = attrs.model.showQaTip; 7 | 8 | let topicType = oneToMany ? "qa_one_to_many" : "qa"; 9 | let label = `topic.tip.${topicType}.title`; 10 | let details = `topic.tip.${topicType}.details`; 11 | let detailsOpts = { 12 | tl1Limit: siteSettings.qa_tl1_vote_limit, 13 | tl2Limit: siteSettings.qa_tl2_vote_limit, 14 | tl3Limit: siteSettings.qa_tl3_vote_limit, 15 | tl4Limit: siteSettings.qa_tl4_vote_limit 16 | }; 17 | 18 | component.setProperties({ 19 | showTip, 20 | label, 21 | details, 22 | detailsOpts 23 | }); 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /spec/components/question_answer/topic_tag_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../plugin_helper' 4 | 5 | describe QuestionAnswer::TopicTagExtension do 6 | fab!(:topic) { Fabricate(:topic) } 7 | fab!(:qa_tag) { Fabricate(:tag, name: 'question') } 8 | fab!(:non_qa_tag) { Fabricate(:tag, name: 'tag1') } 9 | fab!(:topic_tag_qa) { Fabricate(:topic_tag, tag: qa_tag, topic: topic) } 10 | fab!(:topic_tag_non_qa) { Fabricate(:topic_tag, tag: non_qa_tag, topic: topic) } 11 | 12 | it 'should call callback correctly' do 13 | expect(topic_tag_qa.qa_tag?).to eq(true) 14 | TopicTag.any_instance.expects(:update_post_order).once 15 | 16 | topic_tag_qa.destroy # destroy to test if callback called 17 | 18 | expect(topic_tag_non_qa.qa_tag?).to eq(false) 19 | TopicTag.any_instance.expects(:update_post_order).never 20 | 21 | topic_tag_non_qa.destroy 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/lib/qa-utilities.js.es6: -------------------------------------------------------------------------------- 1 | import { popupAjaxError } from "discourse/lib/ajax-error"; 2 | import { ajax } from "discourse/lib/ajax"; 3 | 4 | const voteActionId = 100; 5 | 6 | const vote = function(type, data) { 7 | return ajax("/qa/vote", { 8 | type, 9 | data 10 | }).catch(popupAjaxError); 11 | }; 12 | 13 | const undoVote = function(data) { 14 | return vote("DELETE", data); 15 | }; 16 | 17 | const castVote = function(data) { 18 | return vote("POST", data); 19 | }; 20 | 21 | const whoVoted = function(data) { 22 | return ajax("/qa/voters", { 23 | type: "GET", 24 | data 25 | }).catch(popupAjaxError); 26 | }; 27 | 28 | export function setAsAnswer(post) { 29 | return ajax("/qa/set_as_answer", { 30 | type: "POST", 31 | data: { 32 | post_id: post.id 33 | } 34 | }).catch(popupAjaxError); 35 | } 36 | 37 | export { undoVote, castVote, voteActionId, whoVoted }; 38 | -------------------------------------------------------------------------------- /assets/stylesheets/desktop/question-answer.scss: -------------------------------------------------------------------------------- 1 | .qa-post { 2 | padding-right: 20px; 3 | } 4 | 5 | .qa-post + article { 6 | position: relative; 7 | float: left; 8 | width: calc(100% - 65px); 9 | } 10 | 11 | .topic-post.answer .topic-body { 12 | width: 653px; 13 | } 14 | 15 | .topic-post.comment { 16 | padding-left: 110px; 17 | max-width: 647px; 18 | 19 | .topic-avatar { 20 | width: 30px; 21 | } 22 | 23 | .topic-body { 24 | width: calc(100% - 30px); 25 | padding: 10px 0 10px 0; 26 | 27 | .regular { 28 | margin-top: 0; 29 | 30 | p { 31 | margin: 0; 32 | } 33 | 34 | nav.post-controls .actions button { 35 | font-size: 0.9em; 36 | padding: 6px 8px; 37 | } 38 | } 39 | 40 | .post-actions { 41 | margin: 0; 42 | } 43 | 44 | .post-menu-area { 45 | margin-top: 5px; 46 | margin-bottom: 0; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /config/settings.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | qa_tags: 3 | client: true 4 | type: list 5 | default: 'question' 6 | qa_show_topic_tip: 7 | client: true 8 | default: true 9 | qa_enabled: 10 | client: true 11 | default: true 12 | qa_disable_like_on_answers: 13 | client: true 14 | default: false 15 | qa_undo_vote_action_window: 16 | default: 10 17 | qa_comments_default: 18 | default: 3 19 | client: true 20 | qa_trust_level_vote_limits: 21 | default: false 22 | client: true 23 | qa_tl1_vote_limit: 24 | default: 1 25 | client: true 26 | qa_tl2_vote_limit: 27 | default: 2 28 | client: true 29 | qa_tl3_vote_limit: 30 | default: 3 31 | client: true 32 | qa_tl4_vote_limit: 33 | default: 4 34 | client: true 35 | qa_tl_allow_multiple_votes_per_post: 36 | default: false 37 | client: true 38 | qa_blacklist_tags: 39 | type: list 40 | default: "" 41 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/components/qa-topic-tip.js.es6: -------------------------------------------------------------------------------- 1 | import { cookAsync } from "discourse/lib/text"; 2 | 3 | export default Ember.Component.extend({ 4 | classNames: ["qa-topic-tip"], 5 | 6 | didInsertElement() { 7 | this._super(...arguments); 8 | 9 | $(document).on("click", Ember.run.bind(this, this.documentClick)); 10 | 11 | const rawDetails = I18n.t(this.details, this.detailsOpts); 12 | 13 | if (rawDetails) { 14 | cookAsync(rawDetails).then(cooked => { 15 | this.set("cookedDetails", cooked); 16 | }); 17 | } 18 | }, 19 | 20 | willDestroyElement() { 21 | $(document).off("click", Ember.run.bind(this, this.documentClick)); 22 | }, 23 | 24 | documentClick(e) { 25 | const $element = $(this.element); 26 | const $target = $(e.target); 27 | 28 | if ($target.closest($element).length < 1 && this._state !== "destroying") { 29 | this.set("showDetails", false); 30 | } 31 | }, 32 | 33 | actions: { 34 | toggleDetails() { 35 | this.toggleProperty("showDetails"); 36 | } 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /config/locales/server.fr.yml: -------------------------------------------------------------------------------- 1 | fr: 2 | site_settings: 3 | qa_tags: "Tags pour activer QnA sur le sujet" 4 | qa_show_topic_tip: "Afficher une astuce sur la mécanique des sujets QnA sous les titres de sujets QnA" 5 | qa_enabled: "Activer le plugin QA" 6 | qa_disable_like_on_answers: "Désactiver le bouton de type de réponse sur les sujets de questions et réponses" 7 | qa_undo_vote_action_window: "Nombre de minutes où les utilisateurs sont autorisés à annuler les votes dans les sujets QnA (entrez 0 pour aucune limite)" 8 | qa_comments_default: "Nombre maximum de commentaires à afficher par défaut." 9 | 10 | post_action_types: 11 | vote: 12 | title: 'Vote' 13 | description: 'Votez pour ce post' 14 | short_description: 'Votez pour ce post' 15 | long_form: 'voté pour ce post' 16 | 17 | vote: 18 | error: 19 | user_has_not_voted: "L'utilisateur n'a pas voté." 20 | already_voted: "Vous ne pouvez voter qu'une fois par question." 21 | undo_vote_action_window: "Vous ne pouvez annuler des votes %{minutes}qu'après avoir voté." 22 | -------------------------------------------------------------------------------- /spec/components/question_answer/guardian_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../plugin_helper' 4 | 5 | describe QuestionAnswer::GuardianExtension do 6 | fab!(:user1) { Fabricate(:user) } 7 | fab!(:user2) { Fabricate(:user) } 8 | fab!(:category) { Fabricate(:category) } 9 | fab!(:topic) { Fabricate(:topic, category: category, user: user1) } 10 | let(:post_opts) { { raw: 'blah' } } 11 | 12 | before do 13 | category.custom_fields['qa_enabled'] = true 14 | category.custom_fields['qa_one_to_many'] = true 15 | 16 | category.save! 17 | category.reload 18 | end 19 | 20 | it 'should can create post if user.id equal topic.user_id' do 21 | guardian = Guardian.new(user1) 22 | guardian.post_opts = post_opts 23 | 24 | expect(guardian.can_create_post_on_topic?(topic)).to eq(true) 25 | end 26 | 27 | it "should can't create post if user.id not equal topic.user_id" do 28 | guardian = Guardian.new(user2) 29 | guardian.post_opts = post_opts 30 | 31 | expect(guardian.can_create_post_on_topic?(topic)).to eq(false) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /config/locales/server.es.yml: -------------------------------------------------------------------------------- 1 | es: 2 | site_settings: 3 | qa_tags: "Etiquetas para activar PyR en un tema" 4 | qa_show_topic_tip: "Mostrar un consejo sobre las mecánicas de los temas PyR debajo de los títulos de temas PyR" 5 | qa_enabled: "Activar el plugin de Preguntas y Respuestas (Q&A - PyR)" 6 | qa_disable_like_on_answers: "Desactiva el botón de me gusta en las respuestas de temas PyR" 7 | qa_undo_vote_action_window: "Número de minutos en los cuales los usuarios tienen permitido deshacer sus votos en temas PyR (introducir 0 para no poner límite)" 8 | qa_comments_default: "Número máximo de comentarios a mostrar por defecto." 9 | 10 | post_action_types: 11 | vote: 12 | title: 'Votar' 13 | description: 'Votar este mensaje' 14 | short_description: 'Votar este mensaje' 15 | long_form: 'ha votado este mensaje' 16 | 17 | vote: 18 | error: 19 | user_has_not_voted: "El usuario no ha votado." 20 | already_voted: "Solo puedes votar una vez por pregunta." 21 | undo_vote_action_window: "Solo puedes deshacer votos hasta %{minutes} después de votar." 22 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/connectors/after-topic-footer-main-buttons/answer-button-wrapper.js.es6: -------------------------------------------------------------------------------- 1 | import { getOwner } from "discourse-common/lib/get-owner"; 2 | 3 | export default { 4 | setupComponent(attrs, component) { 5 | const currentUser = component.get("currentUser"); 6 | const topic = attrs.topic; 7 | const oneToMany = topic.category && topic.category.qa_one_to_many; 8 | const qaEnabled = topic.qa_enabled; 9 | const canCreatePost = topic.get("details.can_create_post"); 10 | 11 | let showCreateAnswer = 12 | qaEnabled && 13 | canCreatePost && 14 | (!oneToMany || topic.user_id == currentUser.id); 15 | let label; 16 | let title; 17 | 18 | if (showCreateAnswer) { 19 | let topicType = oneToMany ? "one_to_many" : "answer"; 20 | label = `topic.${topicType}.title`; 21 | title = `topic.${topicType}.help`; 22 | } 23 | 24 | component.setProperties({ 25 | showCreateAnswer, 26 | label, 27 | title 28 | }); 29 | }, 30 | 31 | actions: { 32 | answerQuestion() { 33 | const controller = getOwner(this).lookup("controller:topic"); 34 | controller.send("replyToPost"); 35 | } 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /spec/components/question_answer/category_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../plugin_helper' 4 | 5 | describe QuestionAnswer::CategoryCustomFieldExtension do 6 | it 'should call callback correctly' do 7 | custom_field = CategoryCustomField.new(name: 'qa_enabled') 8 | 9 | expect(custom_field.qa_enabled_changed).to eq(true) 10 | 11 | custom_field.name = 'random_name' 12 | 13 | expect(custom_field.qa_enabled_changed).to eq(false) 14 | end 15 | end 16 | 17 | describe QuestionAnswer::CategoryExtension do 18 | fab!(:category) { Fabricate(:category) } 19 | let(:fields) do 20 | %w[ 21 | qa_enabled 22 | qa_one_to_many 23 | qa_disable_like_on_answers 24 | qa_disable_like_on_questions 25 | qa_disable_like_on_comments 26 | ] 27 | end 28 | 29 | it 'should cast custom fields correctly' do 30 | fields.each do |f| 31 | expect(category.send(f)).to eq(false) 32 | end 33 | 34 | fields.each do |f| 35 | category.custom_fields[f] = true 36 | end 37 | 38 | category.save_custom_fields 39 | category.reload 40 | 41 | fields.each do |f| 42 | expect(category.send(f)).to eq(true) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/widgets/qa-post.js.es6: -------------------------------------------------------------------------------- 1 | import { createWidget } from "discourse/widgets/widget"; 2 | import { castVote } from "../lib/qa-utilities"; 3 | import { h } from "virtual-dom"; 4 | 5 | export default createWidget("qa-post", { 6 | tagName: "div.qa-post", 7 | 8 | sendShowLogin() { 9 | const appRoute = this.register.lookup("route:application"); 10 | appRoute.send("showLogin"); 11 | }, 12 | 13 | html(attrs) { 14 | const contents = [ 15 | this.attach("qa-button", { direction: "up" }), 16 | h("div.count", `${attrs.count}`) 17 | ]; 18 | return contents; 19 | }, 20 | 21 | vote(direction) { 22 | const user = this.currentUser; 23 | 24 | if (!user) { 25 | return this.sendShowLogin(); 26 | } 27 | 28 | const post = this.attrs.post; 29 | 30 | let vote = { 31 | user_id: user.id, 32 | post_id: post.id, 33 | direction 34 | }; 35 | 36 | castVote({ vote }).then(result => { 37 | if (result.success) { 38 | post.set("topic.qa_voted", true); 39 | 40 | if (result.qa_can_vote) { 41 | post.set("topic.qa_can_vote", result.qa_can_vote); 42 | } 43 | if (result.qa_votes) { 44 | post.set("topic.qa_votes", result.qa_votes); 45 | } 46 | } 47 | }); 48 | } 49 | }); 50 | -------------------------------------------------------------------------------- /spec/jobs/update_topic_post_order_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../plugin_helper' 4 | 5 | describe Jobs::UpdateTopicPostOrder do 6 | fab!(:topic) { Fabricate(:topic) } 7 | fab!(:post1) { Fabricate(:post, topic: topic, post_number: 1) } 8 | fab!(:post2) { Fabricate(:post, topic: topic, post_number: 2) } 9 | fab!(:post3) { Fabricate(:post, topic: topic, post_number: 3) } 10 | fab!(:post4) { Fabricate(:post, topic: topic, post_number: 4, reply_to_post_number: 2) } 11 | fab!(:tag) { Fabricate(:tag, name: 'question') } 12 | 13 | it "when qa is enabled it sets topic post sort order as qa order" do 14 | topic.tags = [tag] 15 | topic.save! 16 | topic.reload 17 | 18 | Jobs::UpdateTopicPostOrder.new.execute(topic_id: topic.id) 19 | 20 | expect(post1.reload.sort_order).to eq(1) 21 | expect(post2.reload.sort_order).to eq(2) 22 | expect(post3.reload.sort_order).to eq(4) 23 | expect(post4.reload.sort_order).to eq(3) 24 | end 25 | 26 | it "when qa is disabled it sets topic post sort order as post number" do 27 | Jobs::UpdateTopicPostOrder.new.execute(topic_id: topic.id) 28 | 29 | expect(post1.reload.sort_order).to eq(1) 30 | expect(post2.reload.sort_order).to eq(2) 31 | expect(post3.reload.sort_order).to eq(3) 32 | expect(post4.reload.sort_order).to eq(4) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/jobs/update_category_post_order_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../plugin_helper' 4 | 5 | describe Jobs::UpdateTopicPostOrder do 6 | fab!(:category) { Fabricate(:category) } 7 | fab!(:topic) { Fabricate(:topic, category: category) } 8 | fab!(:post1) { Fabricate(:post, topic: topic, post_number: 1) } 9 | fab!(:post2) { Fabricate(:post, topic: topic, post_number: 2) } 10 | fab!(:post3) { Fabricate(:post, topic: topic, post_number: 3) } 11 | fab!(:post4) { Fabricate(:post, topic: topic, post_number: 4, reply_to_post_number: 2) } 12 | 13 | it "when qa is enabled it sets topics post sort order as qa order" do 14 | category.custom_fields['qa_enabled'] = true 15 | category.save_custom_fields(true) 16 | 17 | Jobs::UpdateCategoryPostOrder.new.execute(category_id: category.id) 18 | 19 | expect(post1.reload.sort_order).to eq(1) 20 | expect(post2.reload.sort_order).to eq(2) 21 | expect(post3.reload.sort_order).to eq(4) 22 | expect(post4.reload.sort_order).to eq(3) 23 | end 24 | 25 | it "when qa is disabled it sets topics post sort order as post number" do 26 | Jobs::UpdateCategoryPostOrder.new.execute(category_id: category.id) 27 | 28 | expect(post1.reload.sort_order).to eq(1) 29 | expect(post2.reload.sort_order).to eq(2) 30 | expect(post3.reload.sort_order).to eq(3) 31 | expect(post4.reload.sort_order).to eq(4) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /config/locales/client.it.yml: -------------------------------------------------------------------------------- 1 | it: 2 | js: 3 | category: 4 | enable_qa: "Abilita lo stile Q&A per gli argomenti di questa categoria." 5 | 6 | composer: 7 | composer_actions: 8 | reply_to_question: 9 | label: "" 10 | desc: "" 11 | comment_on_answer: 12 | label: "" 13 | desc: "" 14 | 15 | post: 16 | actions: 17 | undo: 18 | vote: "" 19 | people: 20 | vote: "" 21 | by_you: 22 | vote: "" 23 | by_you_and_others: 24 | vote: 25 | one: "" 26 | other: "" 27 | 28 | by_others: 29 | vote: 30 | one: "" 31 | other: "" 32 | 33 | 34 | topic: 35 | answer: 36 | title: '' 37 | help: '' 38 | comment: 39 | title: '' 40 | help: '' 41 | show_comments: 42 | all: '' 43 | more: '' 44 | 45 | tip: 46 | qa: 47 | title: "Argomento Q&A" 48 | details: "Gli utenti possono votare il messaggio che meglio risponde al messaggio iniziale. " 49 | 50 | vote: 51 | already_voted: "Puoi votare una sola volta per domanda." 52 | 53 | last_answer_lowercase: "" 54 | answer_lowercase: 55 | one: '' 56 | other: '' 57 | 58 | -------------------------------------------------------------------------------- /config/locales/server.he.yml: -------------------------------------------------------------------------------- 1 | he: 2 | site_settings: 3 | qa_tags: "תגיות כדי לאפשר Q&A בפוסט" 4 | qa_show_topic_tip: "הצג טיפ על המכניזם של נושאי Q&A מתחת לכותרת" 5 | qa_enabled: "אפשר תוסף Q&A" 6 | qa_disable_like_on_answers: "השבת את כפתור הלייק בפוסטים של Q&A" 7 | qa_undo_vote_action_window: "מספר הדקות שמשתמשים רשאים לבטל הצבעות בנושאי QnA (הזן 0 ללא הגבלה)" 8 | qa_comments_default: "מספר תגובות מקסימלי להציג כברירת מחדל" 9 | qa_trust_level_vote_limits: "השתמש במגבלות הצבעה ברמת אמון עבור התוסף Q&A" 10 | qa_tl1_vote_limit: "כמות ההצבעות המקסימלית לרמת אמון 1 עבור תוסף Q&A" 11 | qa_tl2_vote_limit: "כמות ההצבעות המקסימלית לרמת אמון 2 עבור תוסף Q&A" 12 | qa_tl3_vote_limit: "כמות ההצבעות המקסימלית לרמת אמון 3 עבור תוסף Q&A" 13 | qa_tl4_vote_limit: "כמות ההצבעות המקסימלית לרמת אמון 4 עבור תוסף Q&A" 14 | qa_tl_allow_multiple_votes_per_post: "אפשר למשתמשים להצביע מספר פעמים על אותו פוסט." 15 | 16 | post_action_types: 17 | vote: 18 | title: 'הצבעה' 19 | description: 'הצבעה עבור פוסט' 20 | short_description: 'הצבעה עבור פוסט' 21 | long_form: 'הצבעת עבור הפוסט' 22 | 23 | vote: 24 | error: 25 | user_has_not_voted: "משתמש לא הצביע." 26 | user_over_limit: "הגעת לכמות המקסימלית של ההצבעות." 27 | already_voted: "ניתן להצביע רק פעם אחת לשאלה." 28 | undo_vote_action_window: "ניתן לבטל הצבעות %{minutes} דקות לאחר ההצבעה." 29 | one_vote_per_post: "אתה יכול להצביע רק פעם אחת לכל פוסט." 30 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/connectors/category-custom-settings/enable-qa.hbs: -------------------------------------------------------------------------------- 1 |
2 |

{{i18n "category.qa_settings_label"}}

3 | 4 |
5 | 13 |
14 | 15 |
16 | 24 |
25 | 26 |
27 | 35 |
36 | 37 |
38 | 46 |
47 | 48 |
49 | 57 |
58 |
59 | -------------------------------------------------------------------------------- /spec/serializers/question_answer/topic_list_item_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../plugin_helper' 4 | 5 | describe QuestionAnswer::TopicListItemSerializerExtension do 6 | fab!(:category) { Fabricate(:category) } 7 | fab!(:topic) { Fabricate(:topic, category: category) } 8 | let(:enable_category) do 9 | ->() do 10 | category.custom_fields['qa_enabled'] = true 11 | category.save! 12 | category.reload 13 | end 14 | end 15 | let(:create_serializer) do 16 | ->() do 17 | TopicListItemSerializer.new( 18 | topic, 19 | scope: Guardian.new, 20 | root: false 21 | ).as_json 22 | end 23 | end 24 | let(:custom_attrs) do 25 | %i[qa_enabled answer_count] 26 | end 27 | 28 | context 'enabled' do 29 | before do 30 | enable_category.call 31 | end 32 | 33 | it 'should include custom attributes' do 34 | serializer = create_serializer.call 35 | 36 | custom_attrs.each do |attr| 37 | expect(serializer.key?(attr)).to eq(true) 38 | end 39 | 40 | expect(serializer[:qa_enabled]).to eq(Topic.qa_enabled(topic)) 41 | expect(serializer[:answer_count]).to eq(topic.answer_count) 42 | end 43 | end 44 | 45 | context 'disabled' do 46 | it 'should not include custom attributes' do 47 | serializer = create_serializer.call 48 | 49 | custom_attrs.each do |attr| 50 | expect(serializer.key?(attr)).to eq(false) 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /db/migrate/20180712004204_save_existing_post_vote_counts_to_custom_fields.rb: -------------------------------------------------------------------------------- 1 | class SaveExistingPostVoteCountsToCustomFields < ::ActiveRecord::Migration[5.1] 2 | def up 3 | vote_totals = {} 4 | 5 | PostAction.where(post_action_type_id: 5).each do |action| 6 | if post = Post.find_by(id: action[:post_id]) 7 | votes = post.qa_vote_history 8 | 9 | votes.push( 10 | "direction": QuestionAnswer::Vote::UP, 11 | "action": QuestionAnswer::Vote::CREATE, 12 | "user_id": action[:user_id].to_s, 13 | "created_at": action[:created_at] 14 | ) 15 | 16 | post.custom_fields['vote_history'] = votes.to_json 17 | post.save_custom_fields(true) 18 | end 19 | 20 | total = vote_totals[action[:post_id]] 21 | total = { count: 0, voted: [] } if total == nil 22 | 23 | total[:count] += 1 24 | 25 | voted = total[:voted] 26 | voted.push(action[:user_id]) 27 | total[:voted] = voted 28 | 29 | vote_totals[action[:post_id]] = total 30 | end 31 | 32 | if vote_totals.any? 33 | vote_totals.each do |k, v| 34 | if post = Post.find_by(id: k) 35 | post.custom_fields['vote_history'] = post.qa_vote_history.to_json 36 | post.custom_fields['vote_count'] = v[:count].to_i 37 | post.custom_fields['voted'] = v[:voted] 38 | post.save_custom_fields(true) 39 | end 40 | end 41 | end 42 | end 43 | 44 | def down 45 | raise ActiveRecord::IrreversibleMigration 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/question_answer/vote.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module QuestionAnswer 4 | class Vote 5 | CREATE = 'create' 6 | DESTROY = 'destroy' 7 | UP = 'up' 8 | DOWN = 'down' 9 | 10 | def self.vote(post, user, args) 11 | modifier = 0 12 | 13 | voted = post.qa_voted 14 | 15 | if args[:direction] == UP 16 | if args[:action] == CREATE 17 | voted.push(user.id) 18 | modifier = 1 19 | elsif args[:action] == DESTROY 20 | modifier = 0 21 | voted.delete_if do |user_id| 22 | if user_id == user.id 23 | modifier -= 1 24 | true 25 | end 26 | end 27 | end 28 | end 29 | 30 | post.custom_fields['vote_count'] = post.qa_vote_count + modifier 31 | post.custom_fields['voted'] = voted 32 | 33 | votes = post.qa_vote_history 34 | 35 | votes.push( 36 | direction: args[:direction], 37 | action: args[:action], 38 | user_id: user.id, 39 | created_at: Time.now 40 | ) 41 | 42 | post.custom_fields['vote_history'] = votes 43 | 44 | if post.save_custom_fields(true) 45 | Topic.qa_update_vote_order(post.topic) 46 | post.publish_change_to_clients! :acted 47 | true 48 | else 49 | false 50 | end 51 | end 52 | 53 | def self.can_undo(post, user) 54 | window = SiteSetting.qa_undo_vote_action_window.to_i 55 | window.zero? || post.qa_last_voted(user.id).to_i > window.minutes.ago.to_i 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > This plugin is no longer maintained. Please use https://github.com/discourse/discourse-question-answer 2 | 3 | # Question Answer Plugin 4 | 5 | This is a [Discourse](https://meta.discourse.org) plugin built by [Pavilion](https://thepavilion.io). 6 | 7 | Pavilion is a freelancer cooperative with three goals 8 | - Deliver high quality work to our clients 9 | - Empower our members financially, professionally and personally 10 | - Support our open source work and those who use it 11 | 12 | [Read more about Pavilion and the work we do](https://thepavilion.io/c/welcome/about). 13 | 14 | ## Features and Bugs 15 | 16 | For more details on the current features of this plugin check out its [companion topic on Discourse Meta](https://meta.discourse.org/t/question-answer-plugin/56032/). 17 | 18 | If you'd like to request a feature, please do so through our [feature request wizard](https://thepavilion.io/w/feature-request). 19 | 20 | If you've found a bug, please report it through our [bug report wizard](https://thepavilion.io/w/bug-report). 21 | 22 | An account on https://thepavilion.io is required to request features and report bugs. We do this to ensure that all relevant information about the feature or bug is captured so we can deal with it as efficiently and effectively as possible. 23 | 24 | ## Contributing 25 | 26 | Pull requests are welcome from anyone. 27 | 28 | If you want to get more involved in open source Discourse work, you may want to consider joining Pavilion as an open source member. [You can read more about joining Pavilion here](https://thepavilion.io/t/how-to-join-pavilion/1605). 29 | 30 | ## Licence 31 | 32 | [GNU General Public Licence, Version 2](./LICENSE.txt) 33 | 34 | -------------------------------------------------------------------------------- /extensions/post_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module QuestionAnswer 4 | module PostExtension 5 | def self.included(base) 6 | base.ignored_columns = %w[vote_count] 7 | base.after_create :qa_update_vote_order, if: :qa_enabled 8 | end 9 | 10 | def qa_vote_count 11 | if vote_count = custom_fields['vote_count'] 12 | [*vote_count].first.to_i 13 | else 14 | 0 15 | end 16 | end 17 | 18 | def qa_voted 19 | if custom_fields['voted'].present? 20 | [*custom_fields['voted']].map(&:to_i) 21 | else 22 | [] 23 | end 24 | end 25 | 26 | def qa_vote_history 27 | if custom_fields['vote_history'].present? 28 | [*custom_fields['vote_history']] 29 | else 30 | [] 31 | end 32 | end 33 | 34 | def qa_enabled 35 | ::Topic.qa_enabled(topic) 36 | end 37 | 38 | def qa_update_vote_order 39 | ::Topic.qa_update_vote_order(topic_id) 40 | end 41 | 42 | def qa_last_voted(user_id) 43 | user_votes = qa_vote_history.select do |v| 44 | v['user_id'].to_i == user_id && v['action'] == 'create' 45 | end 46 | 47 | return unless user_votes.any? 48 | 49 | user_votes 50 | .max_by { |v| v['created_at'].to_datetime.to_i }['created_at'] 51 | .to_datetime 52 | end 53 | 54 | def qa_can_vote(user_id) 55 | SiteSetting.qa_tl_allow_multiple_votes_per_post || 56 | !qa_voted.include?(user_id) 57 | end 58 | 59 | def comments 60 | topic 61 | .posts 62 | .where(reply_to_post_number: self.post_number) 63 | .order('post_number ASC') 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/components/question_answer/vote_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../plugin_helper' 4 | 5 | describe QuestionAnswer::Vote do 6 | fab!(:user) { Fabricate(:user) } 7 | fab!(:post) { Fabricate(:post_with_long_raw_content) } 8 | let(:vote_args) { { direction: 'up', action: 'create' } } 9 | let(:unvote_args) { { direction: 'up', action: 'destroy' } } 10 | 11 | it 'should create a vote' do 12 | vote = QuestionAnswer::Vote.vote(post, user, vote_args) 13 | 14 | expect(vote).to eq(true) 15 | end 16 | 17 | it 'should destroy a vote' do 18 | vote = QuestionAnswer::Vote.vote(post, user, unvote_args) 19 | 20 | expect(vote).to eq(true) 21 | end 22 | 23 | it 'should increment the vote count on create' do 24 | expect(post.qa_vote_count).to eq(0) 25 | 26 | QuestionAnswer::Vote.vote(post, user, vote_args) 27 | 28 | expect(post.qa_vote_count).to eq(1) 29 | end 30 | 31 | it 'should decrement the vote count on destroy' do 32 | QuestionAnswer::Vote.vote(post, user, vote_args) 33 | 34 | expect(post.qa_vote_count).to eq(1) 35 | 36 | QuestionAnswer::Vote.vote(post, user, unvote_args) 37 | 38 | expect(post.qa_vote_count).to eq(0) 39 | end 40 | 41 | it 'should save vote changes to vote history' do 42 | QuestionAnswer::Vote.vote(post, user, vote_args) 43 | 44 | vote_history = post.qa_vote_history 45 | 46 | expect(vote_history[0]['direction']).to eq('up') 47 | expect(vote_history[0]['action']).to eq('create') 48 | expect(vote_history[0]['user_id']).to eq(user.id) 49 | end 50 | 51 | it 'should return the correct undo window' do 52 | expect(SiteSetting.qa_undo_vote_action_window.to_i).to eq(10) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /config/locales/server.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | site_settings: 3 | qa_tags: "Tags to enable QnA on topic" 4 | qa_show_topic_tip: "Show a tip about the mechanics of QnA topics under the titles of QnA topics" 5 | qa_enabled: "Enable QA Plugin" 6 | qa_disable_like_on_answers: "Disables like button on answers in QnA topics" 7 | qa_undo_vote_action_window: "Number of minutes users are allowed to undo votes in QnA topics (enter 0 for no limit)" 8 | qa_comments_default: "Maximum number of comments to display by default." 9 | qa_trust_level_vote_limits: "Use trust level vote limits for the question and answer plugin" 10 | qa_tl1_vote_limit: "The vote limit for the question and answer plugin for trust level 1 users" 11 | qa_tl2_vote_limit: "The vote limit for the question and answer plugin for trust level 2 users" 12 | qa_tl3_vote_limit: "The vote limit for the question and answer plugin for trust level 3 users" 13 | qa_tl4_vote_limit: "The vote limit for the question and answer plugin for trust level 4 users" 14 | qa_tl_allow_multiple_votes_per_post: "Allow users to vote multiple times for the same post." 15 | qa_blacklist_tags: "Tags to disable QnA on topic" 16 | 17 | post_action_types: 18 | vote: 19 | title: 'Vote' 20 | description: 'Vote for this post' 21 | short_description: 'Vote for this post' 22 | long_form: 'voted for this post' 23 | 24 | vote: 25 | error: 26 | user_has_not_voted: "User has not voted." 27 | user_over_limit: "You cannot exceed the number of votes for your trust level." 28 | already_voted: "You can only vote once per question." 29 | undo_vote_action_window: "You can only undo votes %{minutes} after voting." 30 | one_vote_per_post: "You can only vote once for each post." 31 | -------------------------------------------------------------------------------- /config/locales/client.zh_CN.yml: -------------------------------------------------------------------------------- 1 | zh_CN: 2 | js: 3 | category: 4 | enable_qa: "此类别中的帖子允许问答。" 5 | qa_one_to_many: "问答 一对多格式。" 6 | 7 | 8 | composer: 9 | composer_actions: 10 | reply_to_question: 11 | label: 回答这个问题 12 | desc: 在第一个帖子中回答这个问题 13 | comment_on_answer: 14 | label: 评论 %{postUsername} 的答案 15 | desc: 评论答案 16 | one_to_many_entry: 17 | label: 添加新条目 18 | desc: 添加新条目 19 | comment_on_entry: 20 | label: 评论 ${postUsername} 的条目 21 | desc: 评论一个条目 22 | post: 23 | actions: 24 | undo: 25 | vote: "撤销投票" 26 | people: 27 | vote: "投票" 28 | by_you: 29 | vote: "你为这个答案投票了" 30 | by_you_and_others: 31 | vote: 32 | one: "你和另外一个人投票给了这个答案" 33 | other: "你和 {{count}} 个人投票给了这个答案" 34 | 35 | by_others: 36 | vote: 37 | one: "1 个人投票给了这个答案" 38 | other: "{{count}} 个人投票给了这个答案" 39 | 40 | topic: 41 | one_to_many: 42 | title: "新条目" 43 | help: "开始构建新条目" 44 | answer: 45 | title: '答案' 46 | help: '为这个问题添加答案' 47 | comment: 48 | title: '评论' 49 | help: '为这个答案添加评论' 50 | show_comments: 51 | all: '显示 {{count}} 条评论' 52 | more: '显示 {{count}} 条更多评论' 53 | tip: 54 | qa: 55 | title: "问题" 56 | details: "用户可以给最佳回答投票。 57 | " 59 | qa_one_to_many: 60 | title: "一对多" 61 | details: "只有作者可以添加新条目,其他人可以评论。" 62 | 63 | vote: 64 | already_voted: "每个问题您只能投票一次。" 65 | 66 | last_answer_lowercase: 最后答案 67 | last_one_to_many_lowercase: 最后答案 68 | answer_lowercase: 69 | one: 答案 70 | other: 答案 71 | one_to_many_lowercase: 72 | one: 条目 73 | other: 条目 74 | -------------------------------------------------------------------------------- /config/locales/client.fr.yml: -------------------------------------------------------------------------------- 1 | fr: 2 | js: 3 | category: 4 | enable_qa: "Faites tous les sujets de cette catégorie QnA." 5 | 6 | composer: 7 | composer_actions: 8 | reply_to_question: 9 | label: Répondre à la question 10 | desc: Répondez à la question dans le premier post 11 | comment_on_answer: 12 | label: "Commentaire sur la réponse de%{postUsername}" 13 | desc: Commenter une réponse 14 | 15 | post: 16 | actions: 17 | undo: 18 | vote: "Annuler le vote" 19 | people: 20 | vote: "voté pour cela" 21 | by_you: 22 | vote: "Vous avez voté pour ce post" 23 | by_you_and_others: 24 | vote: 25 | one: "Vous et 1 autre avez voté pour ce post" 26 | other: "Vous et {{count}} autres personnes avez voté pour ce message" 27 | 28 | by_others: 29 | vote: 30 | one: "1 personne a voté pour ce post" 31 | other: "{{count}} les gens ont voté pour ce post" 32 | 33 | 34 | topic: 35 | answer: 36 | title: 'Réponse' 37 | help: 'commencer à composer une réponse à la question' 38 | comment: 39 | title: 'Commentaire' 40 | help: 'commencer à rédiger un commentaire sur cette réponse' 41 | show_comments: 42 | all: 'afficher {{count}} commentaires' 43 | more: 'show {{count}} plus de commentaires' 44 | 45 | tip: 46 | qa: 47 | title: "Question" 48 | details: "Les utilisateurs peuvent voter pour la réponse qui répond le mieux au message initial. " 49 | 50 | vote: 51 | already_voted: "Vous ne pouvez voter qu'une fois par question." 52 | 53 | last_answer_lowercase: dernière réponse 54 | answers_lowercase: 55 | one: réponse 56 | other: réponses 57 | 58 | -------------------------------------------------------------------------------- /config/locales/server.de.yml: -------------------------------------------------------------------------------- 1 | de: 2 | site_settings: 3 | qa_tags: "Schlagwörter, die QnA für ein Thema aktivieren" 4 | qa_show_topic_tip: "Zeige einen Hinweis über die Funktionsweise von QnA Themen unter dem Titel des QnA Themas" 5 | qa_enabled: "Aktiviere das QA Plugin" 6 | qa_disable_like_on_answers: "Deaktiviert die Like Schaltfläche für Antworten in QnA Themen" 7 | qa_undo_vote_action_window: "Anzahl an Minuten, in denen Nutzer Stimmen in QnA Themen zurücknehmen können (0 bedeutet kein Limit)" 8 | qa_comments_default: "Maximale Anzahl an Kommentaren, die standardmäßig angezeigt werden." 9 | qa_trust_level_vote_limits: "Setze maximale Anzahl an Stimmen abhängig von der Vertrauensstufe." 10 | qa_tl1_vote_limit: "Maximale Anzahl an Stimmen für Nutzer mit Vertrauensstufe 1" 11 | qa_tl2_vote_limit: "Maximale Anzahl an Stimmen für Nutzer mit Vertrauensstufe 2" 12 | qa_tl3_vote_limit: "Maximale Anzahl an Stimmen für Nutzer mit Vertrauensstufe 3" 13 | qa_tl4_vote_limit: "Maximale Anzahl an Stimmen für Nutzer mit Vertrauensstufe 4" 14 | qa_tl_allow_multiple_votes_per_post: "Erlaube, dass Nutzer mehrfach für einen Beitrag stimmen dürfen." 15 | qa_blacklist_tags: "Schlagwörter, die QnA für ein Thema deaktivieren" 16 | 17 | post_action_types: 18 | vote: 19 | title: 'Abstimmen' 20 | description: 'Stimme für diesen Beitrag' 21 | short_description: 'Stimme für diesen Beitrag' 22 | long_form: 'hat für diesen Beitrag gestimmt' 23 | 24 | vote: 25 | error: 26 | user_has_not_voted: "Nutzer hat nicht abgestimmt." 27 | user_over_limit: "Du kannst nicht öfter abstimmen, als es deine Vertrauensstufe erlaubt." 28 | already_voted: "Du kannst nur einmal pro Frage abstimmen." 29 | undo_vote_action_window: "Du kannst Stimmen für ein Thema maximal %{minutes} nach dem Abstimmen rückgängig machen." 30 | one_vote_per_post: "Du kannst nur einmal pro Beitrag abstimmen." 31 | -------------------------------------------------------------------------------- /config/locales/client.es.yml: -------------------------------------------------------------------------------- 1 | es: 2 | js: 3 | category: 4 | enable_qa: "Hacer todos los temas en esta categoría PyR (Q&A)." 5 | 6 | composer: 7 | composer_actions: 8 | reply_to_question: 9 | label: Responder a la pregunta 10 | desc: Responder a la pregunta en el primer mensaje 11 | comment_on_answer: 12 | label: "Comentar la respuesta de %{postUsername}" 13 | desc: Comentar una respuesta 14 | 15 | post: 16 | actions: 17 | undo: 18 | vote: "Deshacer voto" 19 | people: 20 | vote: "han votado esto" 21 | by_you: 22 | vote: "Has votado este mensaje" 23 | by_you_and_others: 24 | vote: 25 | one: "Tú y otra persona habéis votado este mensaje" 26 | other: "Tú y otras {{count}} personas habéis votado este mensaje" 27 | 28 | by_others: 29 | vote: 30 | one: "1 persona ha votado este mensaje" 31 | other: "{{count}} personas han votado este mensaje" 32 | 33 | 34 | topic: 35 | answer: 36 | title: 'Respuesta' 37 | help: 'empezar a redactar una respuesta a la pregunta' 38 | comment: 39 | title: 'Comentar' 40 | help: 'empezar a redactar un comentario sobre una pregunta' 41 | show_comments: 42 | all: 'mostrar {{count}} comentarios' 43 | more: 'mostrar {{count}} más comentarios' 44 | 45 | tip: 46 | qa: 47 | title: "Pregunta" 48 | details: "Los usuarios pueden votar la respuesta que mejor responda a la pregunta del mensaje inicial. " 49 | 50 | vote: 51 | already_voted: "Solo puedes votar una vez por pregunta." 52 | 53 | last_answer_lowercase: última respuesta 54 | answer_lowercase: 55 | one: respuesta 56 | other: respuestas 57 | 58 | -------------------------------------------------------------------------------- /extensions/topic_view_serializer_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module QuestionAnswer 4 | module TopicViewSerializerExtension 5 | def self.included(base) 6 | base.attributes( 7 | :qa_enabled, 8 | :qa_votes, 9 | :qa_can_vote, 10 | :last_answered_at, 11 | :last_commented_on, 12 | :answer_count, 13 | :comment_count, 14 | :last_answer_post_number, 15 | :last_answerer, 16 | :first_answer_id 17 | ) 18 | end 19 | 20 | def first_answer_id 21 | object.topic.first_answer&.id 22 | end 23 | 24 | def qa_enabled 25 | object.qa_enabled 26 | end 27 | 28 | def qa_votes 29 | Topic.qa_votes(object.topic, scope.current_user) 30 | end 31 | 32 | def qa_can_vote 33 | Topic.qa_can_vote(object.topic, scope.current_user) 34 | end 35 | 36 | def last_answered_at 37 | object.topic.last_answered_at 38 | end 39 | 40 | def include_last_answered_at? 41 | qa_enabled 42 | end 43 | 44 | def last_commented_on 45 | object.topic.last_commented_on 46 | end 47 | 48 | def include_last_commented_on? 49 | qa_enabled 50 | end 51 | 52 | def answer_count 53 | object.topic.answer_count 54 | end 55 | 56 | def include_answer_count? 57 | qa_enabled 58 | end 59 | 60 | def comment_count 61 | object.topic.comment_count 62 | end 63 | 64 | def include_comment_count? 65 | qa_enabled 66 | end 67 | 68 | def last_answer_post_number 69 | object.topic.last_answer_post_number 70 | end 71 | 72 | def include_last_answer_post_number? 73 | qa_enabled 74 | end 75 | 76 | def last_answerer 77 | BasicUserSerializer.new( 78 | object.topic.last_answerer, 79 | scope: scope, 80 | root: false 81 | ).as_json 82 | end 83 | 84 | def include_last_answerer? 85 | qa_enabled 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /config/locales/client.fi.yml: -------------------------------------------------------------------------------- 1 | fi: 2 | js: 3 | category: 4 | enable_qa: "" 5 | 6 | composer: 7 | composer_actions: 8 | reply_to_question: 9 | label: Vastaa kysymykseen 10 | desc: Vastaa alkuperäiseen kysymykseen 11 | comment_on_answer: 12 | label: "Kommentoi käyttäjän %{postUsername} vastausta" 13 | desc: Kommentoi tätä vastausta 14 | 15 | post: 16 | actions: 17 | undo: 18 | vote: "Peru ääni" 19 | people: 20 | vote: "Äänestivät tätä parhaaksi vastaukseksi" 21 | by_you: 22 | vote: "Annoit äänen tälle vastaukselle" 23 | by_you_and_others: 24 | vote: 25 | one: "Sinä ja 1 toinen äänestitte tätä parhaaksi vastaukseksi" 26 | other: "Sinä ja {{count}} muuta äänestitte tätä parhaaksi vastaukseksi" 27 | 28 | by_others: 29 | vote: 30 | one: "1 henkilö äänesti tätä parhaaksi vastaukseksi" 31 | other: "{{count}} henkilöä äänestivät tätä parhaaksi vastaukseksi" 32 | 33 | 34 | topic: 35 | answer: 36 | title: 'Vastaa' 37 | help: 'Kirjoita vastaus kysymykseen' 38 | comment: 39 | title: 'Kommentoi' 40 | help: 'Kommentoi tätä vastausta' 41 | show_comments: 42 | all: 'näytä {{count}} kommenttia' 43 | more: 'näytä {{count}} kommenttia lisää' 44 | 45 | tip: 46 | qa: 47 | title: "Äänestysohjeet" 48 | details: "Äänestä parasta vastausta alkuperäiseen kysymykseen klikkaamalla sinistä peukkua.\n" 49 | 50 | vote: 51 | already_voted: "Voit äänestää vain yhtä vastausta tähän kysymykseen" 52 | 53 | last_answer_lowercase: viimeisin vastaus 54 | answer_lowercase: 55 | one: vastaus 56 | other: vastaukset 57 | 58 | -------------------------------------------------------------------------------- /extensions/post_serializer_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module QuestionAnswer 4 | module PostSerializerExtension 5 | def actions_summary 6 | summaries = super.reject { |s| s[:id] == PostActionType.types[:vote] } 7 | 8 | return summaries unless object.qa_enabled 9 | 10 | user = scope.current_user 11 | summary = { 12 | id: PostActionType.types[:vote], 13 | count: object.qa_vote_count 14 | } 15 | 16 | if user 17 | voted = object.qa_voted.include?(user.id) 18 | 19 | if voted 20 | summary[:acted] = true 21 | summary[:can_undo] = QuestionAnswer::Vote.can_undo(object, user) 22 | else 23 | summary[:can_act] = true 24 | end 25 | end 26 | 27 | summary.delete(:count) if summary[:count].zero? 28 | 29 | if summary[:can_act] || summary[:count] 30 | summaries + [summary] 31 | else 32 | summaries 33 | end 34 | end 35 | 36 | def qa_vote_count 37 | object.qa_vote_count 38 | end 39 | 40 | def qa_voted 41 | object.qa_voted 42 | end 43 | 44 | def qa_enabled 45 | object.qa_enabled 46 | end 47 | 48 | def last_answerer 49 | BasicUserSerializer.new( 50 | object.topic.last_answerer, 51 | scope: scope, 52 | root: false 53 | ).as_json 54 | end 55 | 56 | def include_last_answerer? 57 | object.qa_enabled 58 | end 59 | 60 | def last_answered_at 61 | object.topic.last_answered_at 62 | end 63 | 64 | def include_last_answered_at? 65 | object.qa_enabled 66 | end 67 | 68 | def answer_count 69 | object.topic.answer_count 70 | end 71 | 72 | def include_answer_count? 73 | object.qa_enabled 74 | end 75 | 76 | def last_answer_post_number 77 | object.topic.last_answer_post_number 78 | end 79 | 80 | def include_last_answer_post_number? 81 | object.qa_enabled 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /spec/jobs/qa_update_topics_post_order_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../plugin_helper' 4 | 5 | Fabricator(:post_with_sort_order, from: :post) do 6 | post_number 7 | sort_order 8 | reply_to_post_number 9 | end 10 | 11 | describe Jobs::QAUpdateTopicsPostOrder do 12 | let(:create_post) do 13 | ->(topic, post_number, sort_order, reply_to_post_number = nil) do 14 | args = { 15 | topic: topic, 16 | post_number: post_number, 17 | sort_order: sort_order, 18 | reply_to_post_number: reply_to_post_number 19 | } 20 | 21 | Fabricate(:post_with_sort_order, args) 22 | end 23 | end 24 | fab!(:tag) { Fabricate(:tag, name: 'question') } 25 | fab!(:qa_topic) { Fabricate(:topic, tags: [tag]) } 26 | fab!(:messed_topic) { Fabricate(:topic) } 27 | 28 | it 'should fix post order correctly' do 29 | expect(qa_topic.qa_enabled).to eq(true) 30 | qa_post_1 = create_post.call(qa_topic, 1, 5) 31 | qa_post_2 = create_post.call(qa_topic, 2, 4, 5) 32 | qa_post_3 = create_post.call(qa_topic, 3, 3, 4) 33 | qa_post_4 = create_post.call(qa_topic, 4, 2) 34 | qa_post_5 = create_post.call(qa_topic, 5, 1) 35 | 36 | messed_post_1 = create_post.call(messed_topic, 1, 5) 37 | messed_post_2 = create_post.call(messed_topic, 2, 4) 38 | messed_post_3 = create_post.call(messed_topic, 3, 3) 39 | messed_post_4 = create_post.call(messed_topic, 4, 2) 40 | messed_post_5 = create_post.call(messed_topic, 5, 1) 41 | 42 | Jobs::QaUpdateTopicsPostOrder.new.execute_onceoff({}) 43 | 44 | expect(qa_post_1.reload.sort_order).to eq(1) 45 | expect(qa_post_2.reload.sort_order).to eq(5) 46 | expect(qa_post_3.reload.sort_order).to eq(3) 47 | expect(qa_post_4.reload.sort_order).to eq(2) 48 | expect(qa_post_5.reload.sort_order).to eq(4) 49 | 50 | expect(messed_post_1.reload.sort_order).to eq(1) 51 | expect(messed_post_2.reload.sort_order).to eq(2) 52 | expect(messed_post_3.reload.sort_order).to eq(3) 53 | expect(messed_post_4.reload.sort_order).to eq(4) 54 | expect(messed_post_5.reload.sort_order).to eq(5) 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/serializers/question_answer/topic_view_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../plugin_helper' 4 | 5 | describe QuestionAnswer::TopicViewSerializerExtension do 6 | fab!(:category) { Fabricate(:category) } 7 | fab!(:topic) { Fabricate(:topic, category: category) } 8 | fab!(:post) { Fabricate(:post, topic: topic) } 9 | fab!(:user) { Fabricate(:user) } 10 | let(:topic_view) { TopicView.new(topic, user) } 11 | let(:create_serializer) do 12 | ->() do 13 | scope = Guardian.new(user) 14 | 15 | TopicViewSerializer.new(topic_view, scope: scope, root: false).as_json 16 | end 17 | end 18 | let(:new_attrs) do 19 | %i[ 20 | qa_enabled 21 | qa_votes 22 | qa_can_vote 23 | last_answered_at 24 | last_commented_on 25 | answer_count 26 | comment_count 27 | last_answer_post_number 28 | last_answerer 29 | ] 30 | end 31 | let(:dependent_attrs) do 32 | %i[ 33 | last_answered_at 34 | last_commented_on 35 | answer_count 36 | comment_count 37 | last_answer_post_number 38 | last_answerer 39 | ] 40 | end 41 | 42 | context 'enabled' do 43 | before do 44 | category.custom_fields['qa_enabled'] = true 45 | category.save! 46 | category.reload 47 | end 48 | 49 | it 'should return correct values' do 50 | serializer = create_serializer.call 51 | 52 | expect(serializer[:qa_enabled]).to eq(topic_view.qa_enabled) 53 | expect(serializer[:qa_votes]).to eq(Topic.qa_votes(topic, user)) 54 | expect(serializer[:qa_can_vote]).to eq(Topic.qa_can_vote(topic, user)) 55 | 56 | %i[ 57 | last_answered_at 58 | last_commented_on 59 | answer_count 60 | comment_count 61 | last_answer_post_number 62 | ].each do |attr| 63 | expect(serializer[attr]).to eq(topic.send(attr)) 64 | end 65 | 66 | expect(serializer[:last_answerer].id).to eq(topic.last_answerer.id) 67 | end 68 | end 69 | 70 | context 'disabled' do 71 | before { SiteSetting.qa_enabled = false } 72 | 73 | it 'should not include dependent_attrs' do 74 | serializer = create_serializer.call 75 | 76 | dependent_attrs.each do |attr| 77 | expect(serializer.key?(attr)).to eq(false) 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /config/locales/client.he.yml: -------------------------------------------------------------------------------- 1 | he: 2 | js: 3 | category: 4 | qa_settings_label: "פלאגין תשובה-שאלה" 5 | enable_qa: "הפוך את כל הנושאים בקטגוריה זו להיות בפורמט Q&A." 6 | qa_one_to_many: "פורמט Q&A אחד לרבים." 7 | qa_disable_like_on_answers: "השבת את כפתור הלייק בתשובות של קטגוריה זו Q&A" 8 | qa_disable_like_on_main_post: "השבת את כפתור הלייק בשאלות של קטגוריה זו Q&A" 9 | qa_disable_like_on_comments: "השבת את כפתור הלייק בתגובות של קטגוריה זו Q&A" 10 | 11 | composer: 12 | composer_actions: 13 | reply_to_question: 14 | label: ענה על השאלה 15 | desc: ענה על השאלה בפוסט הראשון 16 | comment_on_answer: 17 | label: "תגובה לתשובה של %{postUsername}" 18 | desc: תגובה לתשובה 19 | one_to_many_entry: 20 | label: הוסף פוסט חדש 21 | desc: הוסף פוסט חדש 22 | comment_on_entry: 23 | label: "הגב על הפוסט של ${postUsername}" 24 | desc: הגב על הפוסט 25 | 26 | post: 27 | actions: 28 | undo: 29 | vote: "בטל הצבעה." 30 | people: 31 | vote: "הצביעו לפוסט." 32 | by_you: 33 | vote: "הצבעת לפוסט זה." 34 | 35 | topic: 36 | one_to_many: 37 | title: "פוסט חדש" 38 | help: "צור פוסט חדש" 39 | answer: 40 | title: 'תשובה' 41 | help: 'הוסף תשובה לשאלה' 42 | comment: 43 | title: 'תגובה' 44 | help: 'הוסף תגובה חדשה' 45 | show_comments: 46 | all: 'הראה {{count}} תגובות' 47 | more: 'הראה {{count}} תגובות נוספות' 48 | 49 | tip: 50 | qa: 51 | title: "שאלה" 52 | details: "משתמשים יכולים להצביע בעד התגובה שעונה הכי טוב על הפוסט. 53 | " 60 | qa_one_to_many: 61 | title: "אחד להרבה" 62 | details: "רק מחבר הנושא יכול לפרסם רשומות. כל המשתמשים יכולים לפרסם תגובות." 63 | 64 | vote: 65 | already_voted: "ניתן להצביע רק פעם אחת לשאלה." 66 | user_over_limit: "הגעת לכמות המקסימלית של ההצבעות." 67 | one_vote_per_post: "ניתן להצביע רק פעם אחת לפוסט." 68 | 69 | last_answer_lowercase: תשובה אחרונה 70 | last_one_to_many_lowercase: פוסט אחרון 71 | answer_lowercase: 72 | one: תשובה 73 | other: תשובות 74 | one_to_many_lowercase: 75 | one: פוסט 76 | other: פוסטים 77 | -------------------------------------------------------------------------------- /config/locales/client.en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | js: 3 | category: 4 | qa_settings_label: "Question Answer" 5 | enable_qa: "Make all topics in this category QnA." 6 | qa_one_to_many: "QnA one-to-many format." 7 | qa_disable_like_on_answers: "Disable likes on answers in this QnA category" 8 | qa_disable_like_on_questions: "Disable likes on questions in this QnA category" 9 | qa_disable_like_on_comments: "Disable likes on comments in this QnA category" 10 | 11 | composer: 12 | composer_actions: 13 | reply_to_question: 14 | label: Answer the question 15 | desc: Answer the question in the first post 16 | comment_on_answer: 17 | label: Comment on answer of %{postUsername} 18 | desc: Comment on an answer 19 | one_to_many_entry: 20 | label: Make a new entry 21 | desc: Make a new entry 22 | comment_on_entry: 23 | label: Comment on entry of ${postUsername} 24 | desc: Comment on an entry 25 | 26 | post: 27 | actions: 28 | undo: 29 | vote: "Undo your upvote." 30 | people: 31 | vote: "upvoted this post." 32 | by_you: 33 | vote: "You upvoted this post." 34 | 35 | topic: 36 | one_to_many: 37 | title: "New Entry" 38 | help: "begin composing a new entry" 39 | answer: 40 | title: 'Answer' 41 | help: 'begin composing an answer to the question' 42 | comment: 43 | title: 'Comment' 44 | help: 'begin composing a comment on this answer' 45 | show_comments: 46 | all: 'show {{count}} comments' 47 | more: 'show {{count}} more comments' 48 | 49 | tip: 50 | qa: 51 | title: "Question" 52 | details: "Users can vote for the response that best answers the initial post. 53 | " 60 | qa_one_to_many: 61 | title: "One to many" 62 | details: "Only the topic author can post entries. All users can post comments." 63 | 64 | vote: 65 | already_voted: "You can only vote once per question." 66 | user_over_limit: "You cannot exceed the number of votes for your trust level." 67 | one_vote_per_post: "You can only vote once for each post." 68 | 69 | user_vote_count: "{{voteCount}} Upvotes" 70 | 71 | last_answer_lowercase: last answer 72 | last_one_to_many_lowercase: last entry 73 | answer_lowercase: 74 | one: answer 75 | other: answers 76 | one_to_many_lowercase: 77 | one: entry 78 | other: entries 79 | qa: 80 | answer_count: "{{answerCount}} Answers" 81 | set_as_answer: "Move to Answers" 82 | -------------------------------------------------------------------------------- /assets/stylesheets/common/question-answer.scss: -------------------------------------------------------------------------------- 1 | .topic-qa-enabled-page { 2 | .time-gap { 3 | display: none; 4 | } 5 | 6 | .qa-answer-count { 7 | padding: 1.5em; 8 | font-size: 1.2em; 9 | box-sizing: border-box; 10 | } 11 | 12 | nav.post-controls .show-replies { 13 | display: none; 14 | } 15 | } 16 | 17 | .qa-post { 18 | float: left; 19 | 20 | .btn { 21 | background: white; 22 | 23 | &:hover { 24 | background: dark-light-diff($primary, $secondary, 65%, -75%); 25 | color: $secondary; 26 | } 27 | } 28 | 29 | .count { 30 | text-align: center; 31 | } 32 | } 33 | 34 | .qa-button .d-icon { 35 | margin: 0; 36 | } 37 | 38 | .time-gap + .topic-post.answer, .time-gap + .topic-post.comment { 39 | .topic-avatar, .topic-body { 40 | border-top: 1px solid $primary-low; 41 | } 42 | } 43 | 44 | .topic-post.answer+.time-gap, .topic-post.comment+.time-gap { 45 | display: none; 46 | } 47 | 48 | nav.post-controls button.comment { 49 | font-size: 1em; 50 | } 51 | 52 | .topic-post { 53 | &.comment { 54 | display: none; 55 | 56 | &.show { 57 | display: block; 58 | } 59 | 60 | .avatar-flair { 61 | width: 12px; 62 | height: 12px; 63 | right: 16px; 64 | 65 | i { 66 | font-size: 8px; 67 | } 68 | } 69 | } 70 | 71 | &.comment, &.answer { 72 | .post-notice.new-user { 73 | display: none; 74 | } 75 | } 76 | } 77 | 78 | .show-comments { 79 | margin-bottom: 10px; 80 | display: inline-block; 81 | } 82 | 83 | .answer .show-comments { 84 | padding-left: 105px; 85 | } 86 | 87 | .vote-container { 88 | 89 | .vote-links { 90 | text-align: right; 91 | 92 | a { 93 | margin-left: 5px; 94 | } 95 | } 96 | 97 | .voters { 98 | margin-top: 5px; 99 | text-align: right; 100 | 101 | a { 102 | margin-right: 5px; 103 | } 104 | } 105 | } 106 | 107 | .user-vote-count { 108 | color: $primary-high; 109 | 110 | strong { 111 | font-size: 1.2em; 112 | color: $primary; 113 | } 114 | } 115 | 116 | // identical to classes in other plugins 117 | 118 | .qa-topic-tip { 119 | position: relative; 120 | margin-top: 5px; 121 | 122 | .tip-details { 123 | background-color: dark-light-diff($primary, $secondary, 95%, -85%); 124 | box-shadow: shadow('dropdown'); 125 | padding: 10px 15px; 126 | position: absolute; 127 | top: 32px; 128 | z-index: 9999; 129 | width: 300px; 130 | 131 | p { 132 | margin: 0; 133 | } 134 | 135 | ul { 136 | margin-bottom: 0; 137 | } 138 | 139 | li { 140 | margin: 2px 0; 141 | } 142 | } 143 | } 144 | 145 | // rtl 146 | 147 | html.rtl { 148 | .qa-post { 149 | padding-right: 0; 150 | padding-left: 20px; 151 | float: right; 152 | 153 | & + article { 154 | float: right; 155 | } 156 | } 157 | 158 | .topic-post.comment { 159 | padding-left: 0; 160 | padding-right: 110px; 161 | } 162 | 163 | .vote-container { 164 | .voters, .vote-links { 165 | text-align: left; 166 | } 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /config/locales/client.de.yml: -------------------------------------------------------------------------------- 1 | de: 2 | js: 3 | category: 4 | qa_settings_label: "Frage Antwort" 5 | enable_qa: "Alle Themen in dieser Kategorie sind QnA." 6 | qa_one_to_many: "QnA 1:N Format." 7 | qa_disable_like_on_answers: "Deaktiviere Likes auf Antworten in dieser QnA Kategorie" 8 | qa_disable_like_on_questions: "Deaktiviere Likes auf Fragen in dieser QnA Kategorie" 9 | qa_disable_like_on_comments: "Deaktiviere Likes auf Kommentare in dieser QnA Kategorie" 10 | 11 | composer: 12 | composer_actions: 13 | reply_to_question: 14 | label: Beantworte die Frage 15 | desc: Beantworte die Frage im ersten Beitrag 16 | comment_on_answer: 17 | label: Kommentiere die Antwort von %{postUsername} 18 | desc: Kommentiere eine Antwort 19 | one_to_many_entry: 20 | label: Erstelle einen neuen Beitrag 21 | desc: Erstelle einen neuen Beitrag 22 | comment_on_entry: 23 | label: Kommentiere den Beitrag von ${postUsername} 24 | desc: Kommentiere einen Beitrag 25 | 26 | post: 27 | actions: 28 | undo: 29 | vote: "Deine Stimme entfernen." 30 | people: 31 | vote: "Stimme(n) für diesen Beitrag" 32 | by_you: 33 | vote: "Du hast für diesen Beitrag gestimmt." 34 | 35 | topic: 36 | one_to_many: 37 | title: "Neues Thema" 38 | help: "beginne damit ein neues Thema zu verfassen" 39 | answer: 40 | title: 'Antworten' 41 | help: 'beginne damit eine Antwort auf diese Frage zu verfassen' 42 | comment: 43 | title: 'Kommentieren' 44 | help: 'beginne damit einen Kommentar zu verfassen' 45 | show_comments: 46 | all: 'zeige {{count}} Kommentare' 47 | more: 'zeige {{count}} Kommentare mehr' 48 | 49 | tip: 50 | qa: 51 | title: "Frage" 52 | details: "Nutzer können für die Antwort stimmen, die die Frage im ersten Beitrag am besten beantwortet. 53 | " 60 | qa_one_to_many: 61 | title: "1:N" 62 | details: "Nur der Autor des Themas kann Beiträge erstellen. Alle Benutzer können Kommentare verfassen." 63 | 64 | vote: 65 | already_voted: "Du kannst nur einmal pro Frage abstimmen." 66 | user_over_limit: "Du kannst nicht öfter abstimmten als es deine Vertrauensstufe erlaubt." 67 | one_vote_per_post: "Du kannst nur einmal für jeden Beitrag abstimmen." 68 | 69 | user_vote_count: "{{voteCount}} Stimmen" 70 | 71 | last_answer_lowercase: letzte Antwort 72 | last_one_to_many_lowercase: letzter Beitrag 73 | answer_lowercase: 74 | one: Antwort 75 | other: Antworten 76 | one_to_many_lowercase: 77 | one: Beitrag 78 | other: Beiträge 79 | qa: 80 | answer_count: "{{answerCount}} Antwort(en)" 81 | -------------------------------------------------------------------------------- /spec/serializers/question_answer/post_serializer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../plugin_helper' 4 | 5 | describe QuestionAnswer::PostSerializerExtension do 6 | fab!(:user) { Fabricate(:user) } 7 | fab!(:category) { Fabricate(:category) } 8 | fab!(:topic) { Fabricate(:topic, category: category) } 9 | fab!(:post) { Fabricate(:post, topic: topic) } 10 | let(:up) { QuestionAnswer::Vote::UP } 11 | let(:create) { QuestionAnswer::Vote::CREATE } 12 | let(:destroy) { QuestionAnswer::Vote::DESTROY } 13 | let(:guardian) { Guardian.new(user) } 14 | let(:vote) do 15 | ->(u) do 16 | QuestionAnswer::Vote.vote(post, u, { direction: up, action: create }) 17 | end 18 | end 19 | let(:undo_vote) do 20 | ->(u) do 21 | QuestionAnswer::Vote.vote(post, u, { direction: up, action: destroy }) 22 | end 23 | end 24 | let(:create_serializer) do 25 | ->(g = guardian) do 26 | PostSerializer.new( 27 | post, 28 | scope: g, 29 | root: false 30 | ).as_json 31 | end 32 | end 33 | let(:dependent_keys) { %i[last_answerer last_answered_at answer_count last_answer_post_number] } 34 | let(:obj_keys) { %i[qa_vote_count qa_voted qa_enabled] } 35 | 36 | context 'qa enabled' do 37 | before do 38 | category.custom_fields['qa_enabled'] = true 39 | category.custom_fields['qa_one_to_many'] = true 40 | 41 | category.save! 42 | category.reload 43 | end 44 | 45 | it 'should qa_enabled' do 46 | serializer = create_serializer.call 47 | 48 | expect(serializer[:qa_enabled]).to eq(true) 49 | end 50 | 51 | describe '#actions_summary' do 52 | let(:get_summary) do 53 | ->(g = guardian) do 54 | serializer = create_serializer.call(g) 55 | 56 | serializer[:actions_summary] 57 | .find { |x| x[:id] == PostActionType.types[:vote] } 58 | end 59 | end 60 | 61 | it 'should not include qa action if has no votes and not logged in' do 62 | g = Guardian.new 63 | 64 | expect(get_summary.call(g)).to eq(nil) 65 | end 66 | 67 | it 'should include qa action if not logged in but has votes' do 68 | g = Guardian.new 69 | vote.call(user) 70 | 71 | expect(get_summary.call(g)).to be_truthy 72 | end 73 | 74 | it 'should include qa summary if has votes' do 75 | vote.call(user) 76 | 77 | expect(get_summary.call).to be_truthy 78 | end 79 | 80 | it 'should can_act if never voted' do 81 | expect(get_summary.call[:can_act]).to eq(true) 82 | end 83 | 84 | it 'should acted if voted' do 85 | vote.call(user) 86 | 87 | expect(get_summary.call[:acted]).to eq(true) 88 | end 89 | end 90 | 91 | it 'should return correct value from post' do 92 | obj_keys.each do |k| 93 | expect(create_serializer.call[k]).to eq(post.public_send(k)) 94 | end 95 | end 96 | 97 | it 'should return correct value from topic' do 98 | dependent_keys.each do |k| 99 | expect(create_serializer.call[k]).to eq(post.topic.public_send(k)) 100 | end 101 | end 102 | end 103 | 104 | context 'qa disabled' do 105 | it 'should not qa_enabled' do 106 | serializer = create_serializer.call 107 | 108 | expect(serializer[:qa_enabled]).to eq(false) 109 | end 110 | 111 | it 'should not include dependent_keys' do 112 | dependent_keys.each do |k| 113 | expect(create_serializer.call.has_key?(k)).to eq(false) 114 | end 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /app/controllers/question_answer/votes_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module QuestionAnswer 4 | class VotesController < ::ApplicationController 5 | before_action :ensure_logged_in 6 | before_action :find_vote_post 7 | before_action :find_vote_user, only: [:create, :destroy] 8 | before_action :ensure_qa_enabled, only: [:create, :destroy] 9 | before_action :ensure_staff, only: [:set_as_answer] 10 | 11 | def create 12 | unless Topic.qa_can_vote(@post.topic, @user) 13 | raise Discourse::InvalidAccess.new( 14 | nil, 15 | nil, 16 | custom_message: 'vote.error.user_over_limit' 17 | ) 18 | end 19 | 20 | unless @post.qa_can_vote(@user.id) 21 | raise Discourse::InvalidAccess.new( 22 | nil, 23 | nil, 24 | custom_message: 'vote.error.one_vote_per_post' 25 | ) 26 | end 27 | 28 | if QuestionAnswer::Vote.vote(@post, @user, vote_args) 29 | render json: success_json.merge( 30 | qa_votes: Topic.qa_votes(@post.topic, @user), 31 | qa_can_vote: Topic.qa_can_vote(@post.topic, @user) 32 | ) 33 | else 34 | render json: failed_json, status: 422 35 | end 36 | end 37 | 38 | def destroy 39 | if Topic.qa_votes(@post.topic, @user).length.zero? 40 | raise Discourse::InvalidAccess.new( 41 | nil, 42 | nil, 43 | custom_message: 'vote.error.user_has_not_voted' 44 | ) 45 | end 46 | 47 | if !QuestionAnswer::Vote.can_undo(@post, @user) 48 | window = SiteSetting.qa_undo_vote_action_window 49 | msg = I18n.t('vote.error.undo_vote_action_window', minutes: window) 50 | 51 | render_json_error(msg, status: 403) 52 | 53 | return 54 | end 55 | 56 | if QuestionAnswer::Vote.vote(@post, @user, vote_args) 57 | render json: success_json.merge( 58 | qa_votes: Topic.qa_votes(@post.topic, @user), 59 | qa_can_vote: Topic.qa_can_vote(@post.topic, @user) 60 | ) 61 | else 62 | render json: failed_json, status: 422 63 | end 64 | end 65 | 66 | def set_as_answer 67 | @post.reply_to_post_number = nil 68 | 69 | @post.save! 70 | Topic.qa_update_vote_order(@post.topic) 71 | 72 | render json: success_json 73 | end 74 | 75 | def voters 76 | voters = [] 77 | 78 | if @post.qa_voted.any? 79 | @post.qa_voted.each do |user_id| 80 | if (user = User.find_by(id: user_id)) 81 | voters.push(QuestionAnswer::Voter.new(user)) 82 | end 83 | end 84 | end 85 | 86 | render_json_dump( 87 | voters: serialize_data(voters, QuestionAnswer::VoterSerializer) 88 | ) 89 | end 90 | 91 | private 92 | 93 | def vote_params 94 | params.require(:vote).permit(:post_id, :user_id, :direction) 95 | end 96 | 97 | def vote_args 98 | { 99 | direction: vote_params[:direction], 100 | action: action_name 101 | } 102 | end 103 | 104 | def find_vote_post 105 | if params[:vote].present? 106 | post_id = vote_params[:post_id] 107 | else 108 | params.require(:post_id) 109 | post_id = params[:post_id] 110 | end 111 | 112 | @post = Post.find_by(id: post_id) 113 | 114 | raise Discourse::NotFound unless @post 115 | end 116 | 117 | def find_vote_user 118 | @user = User.find_by(id: vote_params[:user_id]) 119 | 120 | raise Discourse::NotFound unless @user 121 | end 122 | 123 | def ensure_qa_enabled 124 | raise Discourse::InvalidAccess.new unless Topic.qa_enabled(@post.topic) 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /spec/components/question_answer/post_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../plugin_helper' 4 | 5 | describe QuestionAnswer::PostExtension do 6 | fab!(:user1) { Fabricate(:user) } 7 | fab!(:user2) { Fabricate(:user) } 8 | fab!(:user3) { Fabricate(:user) } 9 | fab!(:post) { Fabricate(:post_with_long_raw_content) } 10 | let(:up) { QuestionAnswer::Vote::UP } 11 | let(:create) { QuestionAnswer::Vote::CREATE } 12 | let(:destroy) { QuestionAnswer::Vote::DESTROY } 13 | let(:users) { [user1, user2, user3] } 14 | let(:vote) do 15 | ->(user) do 16 | QuestionAnswer::Vote.vote(post, user, { direction: up, action: create }) 17 | end 18 | end 19 | let(:undo_vote) do 20 | ->(user) do 21 | QuestionAnswer::Vote.vote(post, user, { direction: up, action: destroy }) 22 | end 23 | end 24 | 25 | it('should ignore vote_count') do 26 | expect(Post.ignored_columns.include?("vote_count")).to eq(true) 27 | end 28 | 29 | it('should include qa_update_vote_order method') do 30 | expect(post.methods.include?(:qa_update_vote_order)).to eq(true) 31 | end 32 | 33 | it 'should return the post vote count correctly' do 34 | # no one voted 35 | expect(post.qa_vote_count).to eq(0) 36 | 37 | users.each do |u| 38 | vote.call(u) 39 | end 40 | 41 | expect(post.qa_vote_count).to eq(users.size) 42 | 43 | users.each do |u| 44 | undo_vote.call(u) 45 | end 46 | 47 | expect(post.qa_vote_count).to eq(0) 48 | end 49 | 50 | it 'should return the post voters correctly' do 51 | users.each do |u| 52 | expect(post.qa_voted.include?(u.id)).to eq(false) 53 | 54 | vote.call(u) 55 | 56 | expect(post.qa_voted.include?(u.id)).to eq(true) 57 | 58 | undo_vote.call(u) 59 | 60 | expect(post.qa_voted.include?(u.id)).to eq(false) 61 | end 62 | end 63 | 64 | it 'should return the post vote history correctly' do 65 | expect(post.qa_vote_history.blank?).to eq(true) 66 | 67 | users.each_with_index do |u, i| 68 | vote.call(u) 69 | 70 | expect(post.qa_vote_history[i]['direction']).to eq(up) 71 | expect(post.qa_vote_history[i]['action']).to eq(create) 72 | expect(post.qa_vote_history[i]['user_id']).to eq(u.id) 73 | end 74 | 75 | users.each_with_index do |u, i| 76 | undo_vote.call(u) 77 | 78 | idx = users.size + i 79 | 80 | expect(post.qa_vote_history[idx]['direction']).to eq(up) 81 | expect(post.qa_vote_history[idx]['action']).to eq(destroy) 82 | expect(post.qa_vote_history[idx]['user_id']).to eq(u.id) 83 | end 84 | end 85 | 86 | it 'should return last voted correctly' do 87 | expect(post.qa_last_voted(user1.id)).to be_falsey 88 | 89 | vote.call(user1) 90 | 91 | # set date 1 month ago 92 | vote_history = post.qa_vote_history 93 | vote_history[0]['created_at'] = 1.month.ago 94 | 95 | post.custom_fields['vote_history'] = vote_history.as_json 96 | post.save 97 | post.reload 98 | 99 | expect(post.qa_last_voted(user1.id) > 1.minute.ago).to eq(false) 100 | 101 | vote.call(user1) 102 | 103 | expect(post.qa_last_voted(user1.id) > 1.minute.ago).to eq(true) 104 | end 105 | 106 | it 'should return the last voter correctly' do 107 | expect(post.qa_voted.last.to_i).to_not eq(user3.id) 108 | 109 | users.each do |u| 110 | vote.call(u) 111 | end 112 | 113 | expect(post.qa_voted.last.to_i).to eq(user3.id) 114 | end 115 | 116 | it 'should return qa_can_vote correctly' do 117 | expect(post.qa_can_vote(user1.id)).to eq(true) 118 | 119 | vote.call(user1) 120 | 121 | expect(post.qa_can_vote(user1.id)).to eq(false) 122 | 123 | SiteSetting.qa_tl_allow_multiple_votes_per_post = true 124 | 125 | expect(post.qa_can_vote(user1.id)).to eq(true) 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /plugin.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # name: discourse-question-answer 4 | # about: Question / Answer Style Topics 5 | # version: 1.6.0 6 | # authors: Angus McLeod, Muhlis Cahyono (muhlisbc@gmail.com) 7 | # url: https://github.com/paviliondev/discourse-question-answer 8 | 9 | %i[common desktop mobile].each do |type| 10 | register_asset "stylesheets/#{type}/question-answer.scss", type 11 | end 12 | 13 | enabled_site_setting :qa_enabled 14 | 15 | after_initialize do 16 | %w( 17 | ../lib/question_answer/engine.rb 18 | ../lib/question_answer/vote.rb 19 | ../lib/question_answer/voter.rb 20 | ../extensions/category_custom_field_extension.rb 21 | ../extensions/category_extension.rb 22 | ../extensions/guardian_extension.rb 23 | ../extensions/post_action_type_extension.rb 24 | ../extensions/post_creator_extension.rb 25 | ../extensions/post_extension.rb 26 | ../extensions/post_serializer_extension.rb 27 | ../extensions/topic_extension.rb 28 | ../extensions/topic_list_item_serializer_extension.rb 29 | ../extensions/topic_tag_extension.rb 30 | ../extensions/topic_view_extension.rb 31 | ../extensions/topic_view_serializer_extension.rb 32 | ../app/controllers/question_answer/votes_controller.rb 33 | ../app/serializers/question_answer/voter_serializer.rb 34 | ../config/routes.rb 35 | ../jobs/update_category_post_order.rb 36 | ../jobs/update_topic_post_order.rb 37 | ../jobs/qa_update_topics_post_order.rb 38 | ).each do |path| 39 | load File.expand_path(path, __FILE__) 40 | end 41 | 42 | if respond_to?(:register_svg_icon) 43 | register_svg_icon 'angle-up' 44 | register_svg_icon 'info' 45 | end 46 | 47 | %w[ 48 | qa_enabled 49 | qa_one_to_many 50 | qa_disable_like_on_answers 51 | qa_disable_like_on_questions 52 | qa_disable_like_on_comments 53 | ].each do |key| 54 | Category.register_custom_field_type(key, :boolean) 55 | add_to_serializer(:basic_category, key.to_sym) { object.send(key) } 56 | 57 | if Site.respond_to?(:preloaded_category_custom_fields) 58 | Site.preloaded_category_custom_fields << key 59 | end 60 | end 61 | 62 | class ::Guardian 63 | attr_accessor :post_opts 64 | prepend QuestionAnswer::GuardianExtension 65 | end 66 | 67 | class ::PostCreator 68 | prepend QuestionAnswer::PostCreatorExtension 69 | end 70 | 71 | class ::PostSerializer 72 | attributes( 73 | :qa_vote_count, 74 | :qa_voted, 75 | :qa_enabled, 76 | :last_answerer, 77 | :last_answered_at, 78 | :answer_count, 79 | :last_answer_post_number 80 | ) 81 | 82 | prepend QuestionAnswer::PostSerializerExtension 83 | end 84 | 85 | register_post_custom_field_type('vote_history', :json) 86 | register_post_custom_field_type('vote_count', :integer) 87 | 88 | class ::Post 89 | include QuestionAnswer::PostExtension 90 | end 91 | 92 | PostActionType.types[:vote] = 100 93 | 94 | class ::PostActionType 95 | singleton_class.prepend QuestionAnswer::PostActionTypeExtension 96 | end 97 | 98 | class ::Topic 99 | include QuestionAnswer::TopicExtension 100 | end 101 | 102 | class ::TopicView 103 | prepend QuestionAnswer::TopicViewExtension 104 | end 105 | 106 | class ::TopicViewSerializer 107 | include QuestionAnswer::TopicViewSerializerExtension 108 | end 109 | 110 | class ::TopicListItemSerializer 111 | include QuestionAnswer::TopicListItemSerializerExtension 112 | end 113 | 114 | class ::Category 115 | include QuestionAnswer::CategoryExtension 116 | end 117 | 118 | class ::CategoryCustomField 119 | include QuestionAnswer::CategoryCustomFieldExtension 120 | end 121 | 122 | class ::TopicTag 123 | include QuestionAnswer::TopicTagExtension 124 | end 125 | 126 | add_to_class(:user, :vote_count) do 127 | post_ids = posts.pluck(:id) 128 | 129 | PostCustomField 130 | .where(post_id: post_ids, name: 'vote_count') 131 | .sum('value::int') 132 | end 133 | 134 | add_to_serializer(:user_card, :vote_count) do 135 | object.vote_count 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /extensions/topic_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module QuestionAnswer 4 | module TopicExtension 5 | def self.included(base) 6 | base.extend(ClassMethods) 7 | end 8 | 9 | def reload(options = nil) 10 | @answers = nil 11 | @comments = nil 12 | @last_answerer = nil 13 | super(options) 14 | end 15 | 16 | def answers 17 | @answers ||= begin 18 | posts 19 | .where(reply_to_post_number: nil) 20 | .order('created_at ASC') 21 | end 22 | end 23 | 24 | def first_answer 25 | posts 26 | .where(reply_to_post_number: nil) 27 | .where.not(post_number: 1) 28 | .order('sort_order') 29 | .first 30 | end 31 | 32 | def comments 33 | @comments ||= begin 34 | posts 35 | .where.not(reply_to_post_number: nil) 36 | .order('created_at ASC') 37 | end 38 | end 39 | 40 | def answer_count 41 | answers.count - 1 ## minus first post 42 | end 43 | 44 | def comment_count 45 | comments.count 46 | end 47 | 48 | def last_answered_at 49 | return unless answers.any? 50 | 51 | answers.last[:created_at] 52 | end 53 | 54 | def last_commented_on 55 | return unless comments.any? 56 | 57 | comments.last[:created_at] 58 | end 59 | 60 | def last_answer_post_number 61 | return unless answers.any? 62 | 63 | answers.last[:post_number] 64 | end 65 | 66 | def last_answerer 67 | return unless answers.any? 68 | 69 | @last_answerer ||= User.find(answers.last[:user_id]) 70 | end 71 | 72 | def qa_enabled 73 | Topic.qa_enabled(self) 74 | end 75 | 76 | # class methods 77 | module ClassMethods 78 | def qa_can_vote(topic, user) 79 | return false if user.blank? || !SiteSetting.qa_enabled 80 | 81 | topic_vote_count = qa_votes(topic, user).length 82 | 83 | if topic_vote_count.positive? && !SiteSetting.qa_trust_level_vote_limits 84 | return false 85 | end 86 | 87 | trust_level = user.trust_level 88 | 89 | return false if trust_level.zero? 90 | 91 | topic_vote_limit = SiteSetting.send("qa_tl#{trust_level}_vote_limit") 92 | topic_vote_limit.to_i > topic_vote_count 93 | end 94 | 95 | # rename to something like qa_user_votes? 96 | def qa_votes(topic, user) 97 | return nil if !user || !SiteSetting.qa_enabled 98 | 99 | PostCustomField.where(post_id: topic.posts.map(&:id), 100 | name: 'voted', 101 | value: user.id).pluck(:post_id) 102 | end 103 | 104 | def qa_enabled(topic) 105 | return false unless SiteSetting.qa_enabled 106 | 107 | return false if !topic || topic&.is_category_topic? 108 | 109 | tags = topic.tags.map(&:name) 110 | 111 | if !(tags & SiteSetting.qa_blacklist_tags.split('|')).empty? 112 | return false 113 | end 114 | 115 | has_qa_tag = !(tags & SiteSetting.qa_tags.split('|')).empty? 116 | is_qa_category = topic.category.present? && topic.category.qa_enabled 117 | is_qa_subtype = topic.subtype == 'question' 118 | 119 | has_qa_tag || is_qa_category || is_qa_subtype 120 | end 121 | 122 | def qa_update_vote_order(topic_id) 123 | return unless SiteSetting.qa_enabled 124 | 125 | posts = Post.where(topic_id: topic_id) 126 | op = posts.find_by(post_number: 1) 127 | 128 | op.update(sort_order: 1) 129 | 130 | count = 2 131 | 132 | # OP comments 133 | op.comments.each do |c| 134 | c.update(sort_order: count) 135 | count += 1 136 | end 137 | 138 | answers = begin 139 | posts 140 | .where(reply_to_post_number: nil) 141 | .where.not(post_number: 1) 142 | .order("( 143 | SELECT COALESCE (( 144 | SELECT value::integer FROM post_custom_fields 145 | WHERE post_id = posts.id AND name = 'vote_count' 146 | ), 0) 147 | ) DESC, post_number ASC") 148 | end 149 | 150 | answers.each do |a| 151 | a.update(sort_order: count) 152 | 153 | comments = a.comments 154 | 155 | if comments.any? 156 | comments.each do |c| 157 | count += 1 158 | c.update(sort_order: count) 159 | end 160 | end 161 | 162 | count += 1 163 | end 164 | end 165 | end 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /spec/requests/question_answer/votes_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../plugin_helper' 4 | 5 | RSpec.describe QuestionAnswer::VotesController do 6 | fab!(:tag) { Fabricate(:tag) } 7 | fab!(:topic) { Fabricate(:topic, tags: [tag]) } 8 | fab!(:qa_post) { Fabricate(:post, topic: topic) } # don't set this as :post 9 | fab!(:qa_user) { Fabricate(:user) } 10 | fab!(:qa_answer) { Fabricate(:post, topic: topic, reply_to_post_number: qa_post.post_number) } 11 | fab!(:admin) { Fabricate(:admin) } 12 | 13 | let(:vote_params) do 14 | { 15 | vote: { 16 | post_id: qa_post.id, 17 | user_id: qa_user.id, 18 | direction: QuestionAnswer::Vote::UP 19 | } 20 | } 21 | end 22 | let(:get_voters) do 23 | ->(params = nil) { get '/qa/voters.json', params: params || vote_params } 24 | end 25 | let(:create_vote) do 26 | ->(params = nil) { post '/qa/vote.json', params: params || vote_params } 27 | end 28 | let(:delete_vote) do 29 | ->(params = nil) { delete '/qa/vote.json', params: params || vote_params } 30 | end 31 | let(:set_as_answer) do 32 | ->(post_id) { post '/qa/set_as_answer.json', params: { post_id: post_id } } 33 | end 34 | 35 | before do 36 | SiteSetting.qa_enabled = true 37 | SiteSetting.qa_tags = tag.name 38 | end 39 | 40 | describe '#ensure_logged_in' do 41 | it 'should return 403 when not logged in' do 42 | get_voters.call 43 | 44 | expect(response.status).to eq(403) 45 | end 46 | end 47 | 48 | context '#find_vote_post' do 49 | before { sign_in(qa_user) } 50 | 51 | it 'should find post by post_id param' do 52 | get_voters.call post_id: qa_post.id 53 | 54 | expect(response.status).to eq(200) 55 | end 56 | 57 | it 'should find post by vote.post_id param' do 58 | get_voters.call 59 | 60 | expect(response.status).to eq(200) 61 | end 62 | 63 | it 'should return 404 if no post found' do 64 | get_voters.call post_id: qa_post.id + 1000 65 | 66 | expect(response.status).to eq(404) 67 | end 68 | end 69 | 70 | describe '#find_vote_user' do 71 | before { sign_in(qa_user) } 72 | 73 | it 'should return 404 if user not found' do 74 | vote_params[:vote][:user_id] += 1000 75 | 76 | create_vote.call 77 | 78 | expect(response.status).to eq(404) 79 | end 80 | end 81 | 82 | describe '#ensure_qa_enabled' do 83 | it 'should return 403 if plugin disabled' do 84 | SiteSetting.qa_enabled = false 85 | 86 | sign_in(qa_user) 87 | create_vote.call 88 | 89 | expect(response.status).to eq(403) 90 | end 91 | end 92 | 93 | describe '#create' do 94 | before { sign_in(qa_user) } 95 | 96 | it 'should success if never voted' do 97 | create_vote.call 98 | 99 | expect(response.status).to eq(200) 100 | end 101 | 102 | it 'should error if already voted' do 103 | create_vote.call 104 | expect(response.status).to eq(200) 105 | 106 | create_vote.call 107 | expect(response.status).to eq(403) 108 | end 109 | end 110 | 111 | describe '#destroy' do 112 | before { sign_in(qa_user) } 113 | 114 | it 'should success if has voted' do 115 | create_vote.call 116 | delete_vote.call 117 | 118 | expect(response.status).to eq(200) 119 | end 120 | 121 | it 'should error if never voted' do 122 | delete_vote.call 123 | 124 | expect(response.status).to eq(403) 125 | end 126 | 127 | it 'should cant undo vote' do 128 | # this takes 1 minute just to sleep 129 | if ENV['QA_TEST_UNDO_VOTE'] 130 | SiteSetting.qa_undo_vote_action_window = 1 131 | 132 | create_vote.call 133 | 134 | sleep 65 135 | 136 | delete_vote.call 137 | 138 | expect(response.status).to eq(403) 139 | 140 | msg = I18n.t('vote.error.undo_vote_action_window', minutes: 1) 141 | 142 | expect(JSON.parse(response.body)['errors'][0]).to eq(msg) 143 | end 144 | end 145 | end 146 | 147 | describe '#voters' do 148 | before { sign_in(qa_user) } 149 | 150 | it 'should return correct users' do 151 | create_vote.call 152 | get_voters.call 153 | 154 | parsed = JSON.parse(response.body) 155 | users = parsed['voters'].map { |u| u['id'] } 156 | 157 | expect(users.include?(qa_user.id)).to eq(true) 158 | end 159 | end 160 | 161 | describe '#set_as_answer' do 162 | context 'admin' do 163 | before { sign_in(admin) } 164 | 165 | it "should set comment as an answer" do 166 | expect(qa_answer.reply_to_post_number).to_not eq(nil) 167 | 168 | set_as_answer.call(qa_answer.id) 169 | 170 | qa_answer.reload 171 | 172 | expect(qa_answer.reply_to_post_number).to eq(nil) 173 | end 174 | end 175 | 176 | context 'user' do 177 | before { sign_in(qa_user) } 178 | 179 | it 'should return 403' do 180 | set_as_answer.call(qa_answer.id) 181 | 182 | expect(response.status).to eq(403) 183 | end 184 | end 185 | end 186 | end 187 | -------------------------------------------------------------------------------- /spec/components/question_answer/topic_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../plugin_helper' 4 | 5 | Fabricator(:comment, from: :post) do 6 | reply_to_post_number 7 | end 8 | 9 | describe QuestionAnswer::TopicExtension do 10 | fab!(:user) { Fabricate(:user) } 11 | fab!(:category) { Fabricate(:category) } 12 | fab!(:topic) { Fabricate(:topic, category: category) } 13 | fab!(:answers) do 14 | 5.times.map { Fabricate(:post, topic: topic) }.sort_by(&:created_at) 15 | end 16 | fab!(:comments) do 17 | 5.times.map do 18 | Fabricate( 19 | :comment, 20 | topic: topic, 21 | reply_to_post_number: 2 22 | ) 23 | end.sort_by(&:created_at) 24 | end 25 | let(:up) { QuestionAnswer::Vote::UP } 26 | let(:create) { QuestionAnswer::Vote::CREATE } 27 | let(:destroy) { QuestionAnswer::Vote::DESTROY } 28 | let(:vote) do 29 | -> (post, u) do 30 | QuestionAnswer::Vote.vote(post, u, direction: up, action: create) 31 | end 32 | end 33 | 34 | it 'should return correct comments' do 35 | comment_ids = comments.map(&:id) 36 | topic_comment_ids = topic.comments.pluck(:id) 37 | 38 | expect(comment_ids).to eq(topic_comment_ids) 39 | end 40 | 41 | it 'should return correct answers' do 42 | answer_ids = answers.map(&:id) 43 | topic_answer_ids = topic.answers.pluck(:id) 44 | 45 | expect(answer_ids).to eq(topic_answer_ids) 46 | end 47 | 48 | it 'should return correct answer_count' do 49 | expect(topic.answers.size).to eq(answers.size) 50 | end 51 | 52 | it 'should return correct comment_count' do 53 | expect(topic.comments.size).to eq(comments.size) 54 | end 55 | 56 | it 'should return correct last_answered_at' do 57 | expected = answers.last.created_at 58 | 59 | expect(topic.last_answered_at).to eq(expected) 60 | end 61 | 62 | it 'should return correct last_commented_on' do 63 | expected = comments.last.created_at 64 | 65 | expect(topic.last_commented_on).to eq(expected) 66 | end 67 | 68 | it 'should return correct last_answer_post_number' do 69 | expected = answers.last.post_number 70 | 71 | expect(topic.last_answer_post_number).to eq(expected) 72 | end 73 | 74 | it 'should return correct last_answerer' do 75 | expected = answers.last.user.id 76 | 77 | expect(topic.last_answerer.id).to eq(expected) 78 | end 79 | 80 | context 'ClassMethods' do 81 | describe '#qa_can_vote' do 82 | it 'should return false if user is blank' do 83 | expect(Topic.qa_can_vote(topic, nil)).to eq(false) 84 | end 85 | 86 | it 'should return false if SiteSetting is disabled' do 87 | SiteSetting.qa_enabled = false 88 | 89 | expect(Topic.qa_can_vote(topic, user)).to eq(false) 90 | end 91 | 92 | it 'return false if user has voted and qa_trust_level_vote_limits is false' do 93 | SiteSetting.qa_trust_level_vote_limits = false 94 | SiteSetting.send("qa_tl#{user.trust_level}_vote_limit=", 10) 95 | 96 | post = answers.first 97 | 98 | vote.call(post, user) 99 | 100 | expect(Topic.qa_can_vote(topic, user)).to eq(false) 101 | 102 | SiteSetting.qa_trust_level_vote_limits = true 103 | 104 | expect(Topic.qa_can_vote(topic, user)).to eq(true) 105 | end 106 | 107 | it 'return false if trust level zero' do 108 | expect(Topic.qa_can_vote(topic, user)).to eq(true) 109 | 110 | user.trust_level = 0 111 | user.save! 112 | 113 | expect(Topic.qa_can_vote(topic, user)).to eq(false) 114 | end 115 | 116 | it 'return false if has voted more than qa_tl*_vote_limit' do 117 | SiteSetting.qa_trust_level_vote_limits = true 118 | 119 | expect(Topic.qa_can_vote(topic, user)).to eq(true) 120 | 121 | SiteSetting.send("qa_tl#{user.trust_level}_vote_limit=", 1) 122 | 123 | vote.call(answers[0], user) 124 | 125 | expect(Topic.qa_can_vote(topic, user)).to eq(false) 126 | 127 | SiteSetting.send("qa_tl#{user.trust_level}_vote_limit=", 2) 128 | 129 | expect(Topic.qa_can_vote(topic, user)).to eq(true) 130 | end 131 | end 132 | 133 | describe '#qa_votes' do 134 | it 'should return nil if user is blank' do 135 | expect(Topic.qa_votes(topic, nil)).to eq(nil) 136 | end 137 | 138 | it 'should return nil if disabled' do 139 | SiteSetting.qa_enabled = false 140 | 141 | expect(Topic.qa_votes(topic, user)).to eq(nil) 142 | end 143 | 144 | it 'should return voted post IDs' do 145 | expected = answers.first(3).map do |a| 146 | vote.call(a, user) 147 | 148 | a.id 149 | end.sort 150 | 151 | expect(Topic.qa_votes(topic, user).sort).to eq(expected) 152 | end 153 | end 154 | 155 | describe '#qa_enabled' do 156 | let(:set_tags) do 157 | lambda do 158 | tags = 2.times.map { Fabricate(:tag) } 159 | topic.tags = tags 160 | SiteSetting.qa_tags = tags.map(&:name).join('|') 161 | 162 | topic.save! 163 | topic.reload 164 | end 165 | end 166 | 167 | it 'should return false if topic is blank' do 168 | expect(Topic.qa_enabled(nil)).to eq(false) 169 | end 170 | 171 | it 'should return false if disabled' do 172 | set_tags.call 173 | SiteSetting.qa_enabled = false 174 | 175 | expect(Topic.qa_enabled(topic)).to eq(false) 176 | end 177 | 178 | it 'should return false if category topic' do 179 | set_tags.call 180 | 181 | category.topic_id = topic.id 182 | category.save! 183 | category.reload 184 | 185 | expect(Topic.qa_enabled(topic)).to eq(false) 186 | end 187 | 188 | it 'should return false by default' do 189 | expect(Topic.qa_enabled(topic)).to eq(false) 190 | end 191 | 192 | it 'should return true if has enabled tags' do 193 | tags = 2.times.map { Fabricate(:tag) } 194 | topic.tags = tags 195 | SiteSetting.qa_tags = tags.map(&:name).join('|') 196 | 197 | expect(Topic.qa_enabled(topic)).to eq(true) 198 | end 199 | 200 | it 'should return true if has blacklist tags' do 201 | tags = 3.times.map { Fabricate(:tag) } 202 | 203 | SiteSetting.qa_blacklist_tags = tags.first.name 204 | SiteSetting.qa_tags = tags.map(&:name).join('|') 205 | 206 | topic.tags = tags 207 | 208 | expect(Topic.qa_enabled(topic)).to eq(false) 209 | end 210 | 211 | it 'should return true on enabled category' do 212 | category.custom_fields['qa_enabled'] = true 213 | category.save! 214 | category.reload 215 | 216 | expect(Topic.qa_enabled(topic)).to eq(true) 217 | end 218 | 219 | it 'should return true if question subtype' do 220 | topic.subtype = 'question' 221 | topic.save! 222 | topic.reload 223 | 224 | expect(Topic.qa_enabled(topic)).to eq(true) 225 | end 226 | end 227 | 228 | describe '#qa_update_vote_order' do 229 | it 'should order by vote count' do 230 | post1 = topic.answers[1] 231 | post2 = topic.answers.last 232 | 233 | expect(post1.sort_order < post2.sort_order).to eq(true) 234 | 235 | vote.call(post2, user) 236 | 237 | post1.reload 238 | post2.reload 239 | 240 | expect(post1.sort_order > post2.sort_order).to eq(true) 241 | expect(post1.post_number < post2.post_number).to eq(true) 242 | end 243 | 244 | it 'should group ordering by answer' do 245 | answer = topic.answers.last 246 | comment = topic.comments.last 247 | 248 | expect(answer.sort_order < comment.sort_order).to eq(true) 249 | 250 | Topic.qa_update_vote_order(topic.id) 251 | 252 | answer.reload 253 | comment.reload 254 | 255 | expect(answer.sort_order > comment.sort_order).to eq(true) 256 | end 257 | end 258 | end 259 | end 260 | -------------------------------------------------------------------------------- /coverage/.resultset.json: -------------------------------------------------------------------------------- 1 | { 2 | "RSpec": { 3 | "coverage": { 4 | "/src/plugins/discourse-question-answer/lib/question_answer/engine.rb": { 5 | "lines": [ 6 | 1, 7 | 1, 8 | 1, 9 | 1, 10 | null, 11 | null 12 | ] 13 | }, 14 | "/src/plugins/discourse-question-answer/lib/question_answer/vote.rb": { 15 | "lines": [ 16 | null, 17 | null, 18 | 1, 19 | 1, 20 | 1, 21 | 1, 22 | 1, 23 | 1, 24 | null, 25 | 1, 26 | 44, 27 | null, 28 | 44, 29 | null, 30 | 44, 31 | 44, 32 | 32, 33 | 32, 34 | 12, 35 | 12, 36 | 12, 37 | 17, 38 | 11, 39 | 11, 40 | null, 41 | null, 42 | null, 43 | null, 44 | null, 45 | 44, 46 | 44, 47 | null, 48 | 44, 49 | null, 50 | 44, 51 | null, 52 | null, 53 | null, 54 | null, 55 | null, 56 | null, 57 | 44, 58 | null, 59 | 44, 60 | 44, 61 | 44, 62 | 44, 63 | null, 64 | 0, 65 | null, 66 | null, 67 | null, 68 | 1, 69 | 3, 70 | 3, 71 | null, 72 | null, 73 | null 74 | ] 75 | }, 76 | "/src/plugins/discourse-question-answer/lib/question_answer/voter.rb": { 77 | "lines": [ 78 | null, 79 | null, 80 | 1, 81 | 1, 82 | null 83 | ] 84 | }, 85 | "/src/plugins/discourse-question-answer/extensions/category_custom_field_extension.rb": { 86 | "lines": [ 87 | null, 88 | null, 89 | 1, 90 | 1, 91 | 1, 92 | 1, 93 | null, 94 | null, 95 | 1, 96 | 2, 97 | null, 98 | null, 99 | 1, 100 | 0, 101 | null, 102 | null, 103 | null 104 | ] 105 | }, 106 | "/src/plugins/discourse-question-answer/extensions/category_extension.rb": { 107 | "lines": [ 108 | null, 109 | null, 110 | 1, 111 | 1, 112 | 1, 113 | 362, 114 | null, 115 | null, 116 | 1, 117 | null, 118 | null, 119 | null, 120 | null, 121 | null, 122 | null, 123 | 367, 124 | null, 125 | null, 126 | null 127 | ] 128 | }, 129 | "/src/plugins/discourse-question-answer/extensions/guardian_extension.rb": { 130 | "lines": [ 131 | null, 132 | null, 133 | 1, 134 | 1, 135 | 1, 136 | 4, 137 | 4, 138 | null, 139 | 4, 140 | null, 141 | null, 142 | null, 143 | null, 144 | null, 145 | 2, 146 | null, 147 | null, 148 | 2, 149 | null, 150 | null, 151 | null 152 | ] 153 | }, 154 | "/src/plugins/discourse-question-answer/extensions/post_action_type_extension.rb": { 155 | "lines": [ 156 | null, 157 | null, 158 | 1, 159 | 1, 160 | 1, 161 | 141, 162 | null, 163 | null, 164 | null 165 | ] 166 | }, 167 | "/src/plugins/discourse-question-answer/extensions/post_creator_extension.rb": { 168 | "lines": [ 169 | null, 170 | null, 171 | 1, 172 | 1, 173 | 1, 174 | 1, 175 | 1, 176 | null, 177 | null, 178 | null 179 | ] 180 | }, 181 | "/src/plugins/discourse-question-answer/extensions/post_extension.rb": { 182 | "lines": [ 183 | null, 184 | null, 185 | 1, 186 | 1, 187 | 1, 188 | 1, 189 | 1, 190 | null, 191 | null, 192 | 1, 193 | 86, 194 | 31, 195 | null, 196 | 55, 197 | null, 198 | null, 199 | null, 200 | 1, 201 | 98, 202 | 30, 203 | null, 204 | 68, 205 | null, 206 | null, 207 | null, 208 | 1, 209 | 71, 210 | 45, 211 | null, 212 | 26, 213 | null, 214 | null, 215 | null, 216 | 1, 217 | 155, 218 | null, 219 | null, 220 | 1, 221 | 5, 222 | null, 223 | null, 224 | 1, 225 | 6, 226 | 6, 227 | null, 228 | null, 229 | 6, 230 | null, 231 | 5, 232 | 6, 233 | null, 234 | null, 235 | null, 236 | 1, 237 | 7, 238 | null, 239 | null, 240 | null, 241 | 1, 242 | 92, 243 | null, 244 | null, 245 | null, 246 | null, 247 | null, 248 | null 249 | ] 250 | }, 251 | "/src/plugins/discourse-question-answer/extensions/post_serializer_extension.rb": { 252 | "lines": [ 253 | null, 254 | null, 255 | 1, 256 | 1, 257 | 1, 258 | 146, 259 | null, 260 | 20, 261 | null, 262 | 14, 263 | null, 264 | 14, 265 | null, 266 | null, 267 | null, 268 | 14, 269 | 12, 270 | null, 271 | 12, 272 | 2, 273 | 2, 274 | null, 275 | 10, 276 | null, 277 | null, 278 | null, 279 | 14, 280 | null, 281 | 14, 282 | 13, 283 | null, 284 | 1, 285 | null, 286 | null, 287 | null, 288 | 1, 289 | 20, 290 | null, 291 | null, 292 | 1, 293 | 20, 294 | null, 295 | null, 296 | 1, 297 | 20, 298 | null, 299 | null, 300 | 1, 301 | 14, 302 | null, 303 | null, 304 | 1, 305 | 20, 306 | null, 307 | null, 308 | 1, 309 | 14, 310 | null, 311 | null, 312 | 1, 313 | 20, 314 | null, 315 | null, 316 | 1, 317 | 14, 318 | null, 319 | null, 320 | 1, 321 | 20, 322 | null, 323 | null, 324 | 1, 325 | 14, 326 | null, 327 | null, 328 | 1, 329 | 20, 330 | null, 331 | null, 332 | null 333 | ] 334 | }, 335 | "/src/plugins/discourse-question-answer/extensions/topic_extension.rb": { 336 | "lines": [ 337 | null, 338 | null, 339 | 1, 340 | 1, 341 | 1, 342 | 1, 343 | null, 344 | null, 345 | 1, 346 | 4, 347 | 4, 348 | 4, 349 | 4, 350 | null, 351 | null, 352 | 1, 353 | 124, 354 | 17, 355 | null, 356 | null, 357 | null, 358 | null, 359 | null, 360 | 1, 361 | 2, 362 | null, 363 | null, 364 | null, 365 | null, 366 | null, 367 | null, 368 | 1, 369 | 9, 370 | 5, 371 | null, 372 | null, 373 | null, 374 | null, 375 | null, 376 | 1, 377 | 19, 378 | null, 379 | null, 380 | 1, 381 | 2, 382 | null, 383 | null, 384 | 1, 385 | 18, 386 | null, 387 | 18, 388 | null, 389 | null, 390 | 1, 391 | 3, 392 | null, 393 | 1, 394 | null, 395 | null, 396 | 1, 397 | 18, 398 | null, 399 | 18, 400 | null, 401 | null, 402 | 1, 403 | 18, 404 | null, 405 | 18, 406 | null, 407 | null, 408 | 1, 409 | 5, 410 | null, 411 | null, 412 | null, 413 | 1, 414 | 1, 415 | 22, 416 | null, 417 | 19, 418 | null, 419 | 19, 420 | 6, 421 | null, 422 | null, 423 | 13, 424 | null, 425 | 13, 426 | null, 427 | 12, 428 | 12, 429 | null, 430 | null, 431 | null, 432 | 1, 433 | 32, 434 | null, 435 | 29, 436 | null, 437 | null, 438 | null, 439 | null, 440 | 1, 441 | 196, 442 | null, 443 | 181, 444 | null, 445 | 179, 446 | null, 447 | 179, 448 | 1, 449 | null, 450 | null, 451 | 178, 452 | 178, 453 | 178, 454 | null, 455 | 178, 456 | null, 457 | null, 458 | 1, 459 | 54, 460 | null, 461 | 54, 462 | 54, 463 | null, 464 | 54, 465 | null, 466 | 54, 467 | null, 468 | null, 469 | 54, 470 | 5, 471 | 5, 472 | null, 473 | null, 474 | null, 475 | 54, 476 | null, 477 | null, 478 | null, 479 | null, 480 | null, 481 | null, 482 | null, 483 | null, 484 | null, 485 | null, 486 | 54, 487 | 38, 488 | null, 489 | 38, 490 | null, 491 | 38, 492 | 14, 493 | 42, 494 | 42, 495 | null, 496 | null, 497 | null, 498 | 38, 499 | null, 500 | null, 501 | null, 502 | null, 503 | null 504 | ] 505 | }, 506 | "/src/plugins/discourse-question-answer/extensions/topic_list_item_serializer_extension.rb": { 507 | "lines": [ 508 | null, 509 | null, 510 | 1, 511 | 1, 512 | 1, 513 | 1, 514 | null, 515 | null, 516 | null, 517 | 1, 518 | 1, 519 | null, 520 | null, 521 | 1, 522 | 4, 523 | null, 524 | null, 525 | 1, 526 | 1, 527 | null, 528 | null, 529 | 1, 530 | 2, 531 | null, 532 | null, 533 | null 534 | ] 535 | }, 536 | "/src/plugins/discourse-question-answer/extensions/topic_tag_extension.rb": { 537 | "lines": [ 538 | null, 539 | null, 540 | 1, 541 | 1, 542 | 1, 543 | 1, 544 | null, 545 | null, 546 | 1, 547 | 4, 548 | 4, 549 | null, 550 | 0, 551 | null, 552 | null, 553 | null, 554 | 1, 555 | 0, 556 | null, 557 | null, 558 | null 559 | ] 560 | }, 561 | "/src/plugins/discourse-question-answer/extensions/topic_view_extension.rb": { 562 | "lines": [ 563 | null, 564 | null, 565 | 1, 566 | 1, 567 | 1, 568 | 15, 569 | null, 570 | null, 571 | null 572 | ] 573 | }, 574 | "/src/plugins/discourse-question-answer/extensions/topic_view_serializer_extension.rb": { 575 | "lines": [ 576 | null, 577 | null, 578 | 1, 579 | 1, 580 | 1, 581 | 1, 582 | null, 583 | null, 584 | null, 585 | null, 586 | null, 587 | null, 588 | null, 589 | null, 590 | null, 591 | null, 592 | null, 593 | null, 594 | null, 595 | 1, 596 | 2, 597 | null, 598 | null, 599 | 1, 600 | 14, 601 | null, 602 | null, 603 | 1, 604 | 2, 605 | null, 606 | null, 607 | 1, 608 | 2, 609 | null, 610 | null, 611 | 1, 612 | 1, 613 | null, 614 | null, 615 | 1, 616 | 2, 617 | null, 618 | null, 619 | 1, 620 | 1, 621 | null, 622 | null, 623 | 1, 624 | 2, 625 | null, 626 | null, 627 | 1, 628 | 1, 629 | null, 630 | null, 631 | 1, 632 | 2, 633 | null, 634 | null, 635 | 1, 636 | 1, 637 | null, 638 | null, 639 | 1, 640 | 2, 641 | null, 642 | null, 643 | 1, 644 | 1, 645 | null, 646 | null, 647 | 1, 648 | 2, 649 | null, 650 | null, 651 | 1, 652 | 1, 653 | null, 654 | null, 655 | null, 656 | null, 657 | null, 658 | null, 659 | 1, 660 | 2, 661 | null, 662 | null, 663 | null 664 | ] 665 | }, 666 | "/src/plugins/discourse-question-answer/app/controllers/question_answer/votes_controller.rb": { 667 | "lines": [ 668 | null, 669 | null, 670 | 1, 671 | 1, 672 | 1, 673 | 1, 674 | 1, 675 | 1, 676 | 1, 677 | null, 678 | 1, 679 | 5, 680 | 1, 681 | null, 682 | null, 683 | null, 684 | null, 685 | null, 686 | null, 687 | 4, 688 | 0, 689 | null, 690 | null, 691 | null, 692 | null, 693 | null, 694 | null, 695 | 4, 696 | 4, 697 | null, 698 | null, 699 | null, 700 | null, 701 | 0, 702 | null, 703 | null, 704 | null, 705 | 1, 706 | 2, 707 | 1, 708 | null, 709 | null, 710 | null, 711 | null, 712 | null, 713 | null, 714 | 1, 715 | 0, 716 | 0, 717 | null, 718 | 0, 719 | null, 720 | 0, 721 | null, 722 | null, 723 | 1, 724 | 1, 725 | null, 726 | null, 727 | null, 728 | null, 729 | 0, 730 | null, 731 | null, 732 | null, 733 | 1, 734 | 1, 735 | null, 736 | 1, 737 | 1, 738 | null, 739 | 1, 740 | null, 741 | null, 742 | 1, 743 | 3, 744 | null, 745 | 3, 746 | 1, 747 | 1, 748 | 1, 749 | null, 750 | null, 751 | null, 752 | null, 753 | 3, 754 | null, 755 | null, 756 | null, 757 | null, 758 | 1, 759 | null, 760 | 1, 761 | 25, 762 | null, 763 | null, 764 | 1, 765 | null, 766 | 5, 767 | null, 768 | null, 769 | null, 770 | null, 771 | 1, 772 | 15, 773 | 11, 774 | null, 775 | 4, 776 | 4, 777 | null, 778 | null, 779 | 15, 780 | null, 781 | 15, 782 | null, 783 | null, 784 | 1, 785 | 9, 786 | null, 787 | 9, 788 | null, 789 | null, 790 | 1, 791 | 8, 792 | null, 793 | null, 794 | null 795 | ] 796 | }, 797 | "/src/plugins/discourse-question-answer/app/serializers/question_answer/voter_serializer.rb": { 798 | "lines": [ 799 | null, 800 | null, 801 | 1, 802 | 1, 803 | 1, 804 | null, 805 | null, 806 | null, 807 | null 808 | ] 809 | }, 810 | "/src/plugins/discourse-question-answer/config/routes.rb": { 811 | "lines": [ 812 | null, 813 | null, 814 | 1, 815 | 1, 816 | 1, 817 | 1, 818 | null, 819 | null, 820 | 1, 821 | 1, 822 | null 823 | ] 824 | }, 825 | "/src/plugins/discourse-question-answer/jobs/update_category_post_order.rb": { 826 | "lines": [ 827 | null, 828 | null, 829 | 1, 830 | 1, 831 | 1, 832 | 2, 833 | null, 834 | 2, 835 | null, 836 | 2, 837 | null, 838 | 2, 839 | 2, 840 | 1, 841 | null, 842 | 1, 843 | 4, 844 | null, 845 | null, 846 | null, 847 | null, 848 | null, 849 | null 850 | ] 851 | }, 852 | "/src/plugins/discourse-question-answer/jobs/update_topic_post_order.rb": { 853 | "lines": [ 854 | null, 855 | null, 856 | 1, 857 | 1, 858 | 1, 859 | 2, 860 | null, 861 | 2, 862 | null, 863 | 2, 864 | 1, 865 | null, 866 | 1, 867 | 4, 868 | null, 869 | null, 870 | null, 871 | null, 872 | null 873 | ] 874 | }, 875 | "/src/plugins/discourse-question-answer/jobs/qa_update_topics_post_order.rb": { 876 | "lines": [ 877 | null, 878 | null, 879 | 1, 880 | 1, 881 | 1, 882 | 1, 883 | 2, 884 | 1, 885 | null, 886 | 1, 887 | 5, 888 | null, 889 | null, 890 | null, 891 | null, 892 | null, 893 | null 894 | ] 895 | } 896 | }, 897 | "timestamp": 1607744378 898 | } 899 | } 900 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /assets/javascripts/discourse/initializers/qa-edits.js.es6: -------------------------------------------------------------------------------- 1 | import { withPluginApi } from "discourse/lib/plugin-api"; 2 | import discourseComputed, { 3 | on, 4 | observes 5 | } from "discourse-common/utils/decorators"; 6 | import { h } from "virtual-dom"; 7 | import { avatarFor } from "discourse/widgets/post"; 8 | import { dateNode, numberNode } from "discourse/helpers/node"; 9 | import { REPLY } from "discourse/models/composer"; 10 | import { undoVote, whoVoted, setAsAnswer } from "../lib/qa-utilities"; 11 | import { smallUserAtts } from "discourse/widgets/actions-summary"; 12 | import PostsWithPlaceholders from "discourse/lib/posts-with-placeholders"; 13 | import { next } from "@ember/runloop"; 14 | 15 | function initPlugin(api) { 16 | const currentUser = api.getCurrentUser(); 17 | 18 | api.reopenWidget("post-menu", { 19 | menuItems() { 20 | const attrs = this.attrs; 21 | let result = this.siteSettings.post_menu.split("|"); 22 | if (attrs.qa_enabled) { 23 | const post = this.findAncestorModel(); 24 | const category = post.topic.category; 25 | 26 | let type = attrs.firstPost 27 | ? "questions" 28 | : attrs.reply_to_post_number 29 | ? "comments" 30 | : "answers"; 31 | 32 | let disableLikes = 33 | this.siteSettings.qa_disable_like_on_answers || 34 | (category && category[`qa_disable_like_on_${type}`]); 35 | 36 | if (disableLikes) { 37 | result = result.filter(b => b !== "like"); 38 | } 39 | 40 | result = result.filter(b => b !== "reply"); 41 | } 42 | return result; 43 | } 44 | }); 45 | 46 | api.decorateWidget("post:before", function(helper) { 47 | const result = []; 48 | const model = helper.getModel(); 49 | const firstAnswer = helper.widget.model.get("topic.first_answer_id"); 50 | 51 | if (helper.attrs.id === firstAnswer && model.qa_enabled) { 52 | const answerCount = helper.widget.model.get("topic.answer_count"); 53 | const answers = I18n.t("qa.answer_count", { answerCount }); 54 | 55 | result.push(helper.h("div.qa-answer-count.small-action", answers)); 56 | } 57 | 58 | if ( 59 | model && 60 | model.get("post_number") !== 1 && 61 | !model.get("reply_to_post_number") && 62 | model.get("qa_enabled") 63 | ) { 64 | const qaPost = helper.attach("qa-post", { 65 | count: model.get("qa_vote_count"), 66 | post: model 67 | }); 68 | 69 | result.push(qaPost); 70 | } 71 | 72 | return result; 73 | }); 74 | 75 | api.decorateWidget("post:after", function(helper) { 76 | const model = helper.getModel(); 77 | if (model.attachCommentToggle && model.hiddenComments > 0) { 78 | let type = 79 | Number(helper.widget.siteSettings.qa_comments_default) > 0 80 | ? "more" 81 | : "all"; 82 | return helper.attach("link", { 83 | action: "showComments", 84 | actionParam: model.answerId, 85 | rawLabel: I18n.t(`topic.comment.show_comments.${type}`, { 86 | count: model.hiddenComments 87 | }), 88 | className: "show-comments" 89 | }); 90 | } 91 | }); 92 | 93 | api.reopenWidget("post-stream", { 94 | buildKey: () => "post-stream", 95 | 96 | defaultState(attrs, state) { 97 | let defaultState = this._super(attrs, state); 98 | defaultState["showComments"] = []; 99 | return defaultState; 100 | }, 101 | 102 | showComments(answerId) { 103 | let showComments = this.state.showComments; 104 | if (showComments.indexOf(answerId) === -1) { 105 | showComments.push(answerId); 106 | this.state.showComments = showComments; 107 | this.appEvents.trigger("post-stream:refresh", { force: true }); 108 | } 109 | }, 110 | 111 | html(attrs, state) { 112 | let posts = attrs.posts || []; 113 | let postArray = this.capabilities.isAndroid ? posts : posts.toArray(); 114 | 115 | if (postArray[0] && postArray[0].qa_enabled) { 116 | let answerId = null; 117 | let showComments = state.showComments; 118 | let defaultComments = Number(this.siteSettings.qa_comments_default); 119 | let commentCount = 0; 120 | let lastVisible = null; 121 | 122 | postArray.forEach((p, i) => { 123 | if (!p.topic) { 124 | return; 125 | } 126 | 127 | p["oneToMany"] = p.topic.category.qa_one_to_many; 128 | 129 | if (p.reply_to_post_number) { 130 | commentCount++; 131 | p["comment"] = true; 132 | p["showComment"] = 133 | showComments.indexOf(answerId) > -1 || 134 | commentCount <= defaultComments; 135 | p["answerId"] = answerId; 136 | p["attachCommentToggle"] = false; 137 | 138 | if (p["showComment"]) lastVisible = i; 139 | 140 | if ( 141 | (!postArray[i + 1] || !postArray[i + 1].reply_to_post_number) && 142 | !p["showComment"] 143 | ) { 144 | postArray[lastVisible]["answerId"] = answerId; 145 | postArray[lastVisible]["attachCommentToggle"] = true; 146 | postArray[lastVisible]["hiddenComments"] = 147 | commentCount - defaultComments; 148 | } 149 | } else { 150 | p["attachCommentToggle"] = !p["oneToMany"]; 151 | p["topicUserId"] = p.topic.user_id; 152 | answerId = p.id; 153 | commentCount = 0; 154 | lastVisible = i; 155 | } 156 | }); 157 | 158 | if (this.capabilities.isAndroid) { 159 | attrs.posts = postArray; 160 | } else { 161 | attrs.posts = PostsWithPlaceholders.create({ 162 | posts: postArray, 163 | store: this.store 164 | }); 165 | } 166 | } 167 | 168 | return this._super(attrs, state); 169 | } 170 | }); 171 | 172 | api.includePostAttributes( 173 | "qa_enabled", 174 | "reply_to_post_number", 175 | "comment", 176 | "showComment", 177 | "answerId", 178 | "lastComment", 179 | "last_answerer", 180 | "last_answered_at", 181 | "answer_count", 182 | "last_answer_post_number", 183 | "last_answerer", 184 | "topicUserId", 185 | "oneToMany" 186 | ); 187 | 188 | api.addPostClassesCallback(attrs => { 189 | if (attrs.qa_enabled && !attrs.firstPost) { 190 | if (attrs.comment) { 191 | let classes = ["comment"]; 192 | if (attrs.showComment) { 193 | classes.push("show"); 194 | } 195 | return classes; 196 | } else { 197 | return ["answer"]; 198 | } 199 | } 200 | }); 201 | 202 | api.addPostMenuButton("answer", attrs => { 203 | if ( 204 | attrs.canCreatePost && 205 | attrs.qa_enabled && 206 | attrs.firstPost && 207 | (!attrs.oneToMany || attrs.topicUserId === currentUser.id) 208 | ) { 209 | let postType = attrs.oneToMany ? "one_to_many" : "answer"; 210 | 211 | let args = { 212 | action: "replyToPost", 213 | title: `topic.${postType}.help`, 214 | icon: "reply", 215 | className: "answer create fade-out" 216 | }; 217 | 218 | if (!attrs.mobileView) { 219 | args.label = `topic.${postType}.title`; 220 | } 221 | 222 | return args; 223 | } 224 | }); 225 | 226 | api.addPostMenuButton("comment", attrs => { 227 | if ( 228 | attrs.canCreatePost && 229 | attrs.qa_enabled && 230 | // !attrs.firstPost && 231 | !attrs.reply_to_post_number 232 | ) { 233 | let args = { 234 | action: "openCommentCompose", 235 | title: "topic.comment.help", 236 | icon: "comment", 237 | className: "comment create fade-out" 238 | }; 239 | 240 | if (!attrs.mobileView) { 241 | args.label = "topic.comment.title"; 242 | } 243 | 244 | return args; 245 | } 246 | }); 247 | 248 | api.modifyClass("component:composer-actions", { 249 | @on("init") 250 | setupPost() { 251 | const composerPost = this.get("composerModel.post"); 252 | if (composerPost) { 253 | this.set("pluginPostSnapshot", composerPost); 254 | } 255 | }, 256 | 257 | @discourseComputed("pluginPostSnapshot") 258 | commenting(post) { 259 | return ( 260 | post && 261 | post.get("topic.qa_enabled") && 262 | // !post.get("firstPost") && 263 | !post.reply_to_post_number 264 | ); 265 | }, 266 | 267 | computeHeaderContent() { 268 | let content = this._super(); 269 | 270 | if ( 271 | this.get("commenting") && 272 | this.get("action") === REPLY && 273 | this.get("options.userAvatar") 274 | ) { 275 | content.icon = "comment"; 276 | } 277 | 278 | return content; 279 | }, 280 | 281 | @discourseComputed("options", "canWhisper", "action", "commenting") 282 | content(options, canWhisper, action, commenting) { 283 | let items = this._super(...arguments); 284 | 285 | if (commenting) { 286 | items.forEach(item => { 287 | if (item.id === "reply_to_topic") { 288 | item.name = I18n.t( 289 | "composer.composer_actions.reply_to_question.label" 290 | ); 291 | item.description = I18n.t( 292 | "composer.composer_actions.reply_to_question.desc" 293 | ); 294 | } 295 | if (item.id === "reply_to_post") { 296 | item.icon = "comment"; 297 | item.name = I18n.t( 298 | "composer.composer_actions.comment_on_answer.label", 299 | { 300 | postUsername: this.get("pluginPostSnapshot.username") 301 | } 302 | ); 303 | item.description = I18n.t( 304 | "composer.composer_actions.comment_on_answer.desc" 305 | ); 306 | } 307 | }); 308 | } 309 | 310 | return items; 311 | } 312 | }); 313 | 314 | api.reopenWidget("post-body", { 315 | buildKey: attrs => `post-body-${attrs.id}`, 316 | 317 | defaultState(attrs) { 318 | let state = this._super(); 319 | if (attrs.qa_enabled) { 320 | state = $.extend({}, state, { voters: [] }); 321 | } 322 | return state; 323 | }, 324 | 325 | html(attrs, state) { 326 | let contents = this._super(attrs, state); 327 | const model = this.findAncestorModel(); 328 | let action = model.actionByName["vote"]; 329 | 330 | if (action && attrs.qa_enabled) { 331 | let voteLinks = []; 332 | 333 | attrs.actionsSummary = attrs.actionsSummary.filter( 334 | as => as.action !== "vote" 335 | ); 336 | 337 | if (action.acted && action.can_undo) { 338 | voteLinks.push( 339 | this.attach("link", { 340 | action: "undoUserVote", 341 | rawLabel: I18n.t("post.actions.undo.vote") 342 | }) 343 | ); 344 | } 345 | 346 | if (action.count > 0) { 347 | voteLinks.push( 348 | this.attach("link", { 349 | action: "toggleWhoVoted", 350 | rawLabel: `${action.count} ${I18n.t("post.actions.people.vote")}` 351 | }) 352 | ); 353 | } 354 | 355 | if (voteLinks.length) { 356 | let voteContents = [h("div.vote-links", voteLinks)]; 357 | 358 | if (state.voters.length) { 359 | voteContents.push( 360 | this.attach("small-user-list", { 361 | users: state.voters, 362 | listClassName: "voters" 363 | }) 364 | ); 365 | } 366 | 367 | let actionSummaryIndex = contents 368 | .map(w => w && w.name) 369 | .indexOf("actions-summary"); 370 | let insertAt = actionSummaryIndex + 1; 371 | 372 | contents.splice( 373 | insertAt - 1, 374 | 0, 375 | h("div.vote-container", voteContents) 376 | ); 377 | } 378 | } 379 | 380 | return contents; 381 | }, 382 | 383 | undoUserVote() { 384 | const post = this.findAncestorModel(); 385 | const user = this.currentUser; 386 | const vote = { 387 | user_id: user.id, 388 | post_id: post.id, 389 | direction: "up" 390 | }; 391 | 392 | undoVote({ vote }).then(result => { 393 | if (result.success) { 394 | post.set("topic.voted", false); 395 | } 396 | }); 397 | }, 398 | 399 | toggleWhoVoted() { 400 | const state = this.state; 401 | if (state.voters.length) { 402 | state.voters = []; 403 | } else { 404 | return this.getWhoVoted(); 405 | } 406 | }, 407 | 408 | getWhoVoted() { 409 | const { attrs, state } = this; 410 | const post = { 411 | post_id: attrs.id 412 | }; 413 | 414 | whoVoted(post).then(result => { 415 | if (result.voters) { 416 | state.voters = result.voters.map(smallUserAtts); 417 | this.scheduleRerender(); 418 | } 419 | }); 420 | } 421 | }); 422 | 423 | api.modifyClass("model:topic", { 424 | @discourseComputed("qa_enabled") 425 | showQaTip(qaEnabled) { 426 | return qaEnabled && this.siteSettings.qa_show_topic_tip; 427 | } 428 | }); 429 | 430 | api.modifyClass("component:topic-footer-buttons", { 431 | @on("didInsertElement") 432 | @observes("topic.qa_enabled") 433 | hideFooterReply() { 434 | const qaEnabled = this.get("topic.qa_enabled"); 435 | Ember.run.scheduleOnce("afterRender", () => { 436 | $( 437 | ".topic-footer-main-buttons > button.create:not(.answer)", 438 | this.element 439 | ).toggle(!qaEnabled); 440 | }); 441 | } 442 | }); 443 | 444 | api.modifyClass("model:post-stream", { 445 | prependPost(post) { 446 | const stored = this.storePost(post); 447 | if (stored) { 448 | const posts = this.get("posts"); 449 | let insertPost = () => posts.unshiftObject(stored); 450 | 451 | const qaEnabled = this.get("topic.qa_enabled"); 452 | if (qaEnabled && post.post_number === 2 && posts[0].post_number === 1) { 453 | insertPost = () => posts.insertAt(1, stored); 454 | } 455 | 456 | insertPost(); 457 | } 458 | 459 | return post; 460 | }, 461 | 462 | appendPost(post) { 463 | const stored = this.storePost(post); 464 | if (stored) { 465 | const posts = this.get("posts"); 466 | 467 | if (!posts.includes(stored)) { 468 | const replyingTo = post.get("reply_to_post_number"); 469 | const qaEnabled = this.get("topic.qa_enabled"); 470 | let insertPost = () => posts.pushObject(stored); 471 | 472 | if (qaEnabled && replyingTo) { 473 | let passed = false; 474 | posts.some((p, i) => { 475 | if (passed && !p.reply_to_post_number) { 476 | insertPost = () => posts.insertAt(i, stored); 477 | return true; 478 | } 479 | 480 | if (p.post_number === replyingTo && i < posts.length - 1) { 481 | passed = true; 482 | } 483 | }); 484 | } 485 | 486 | if (!this.get("loadingBelow")) { 487 | this.get("postsWithPlaceholders").appendPost(insertPost); 488 | } else { 489 | insertPost(); 490 | } 491 | } 492 | 493 | if (stored.get("id") !== -1) { 494 | this.set("lastAppended", stored); 495 | } 496 | } 497 | return post; 498 | } 499 | }); 500 | 501 | api.modifyClass("component:topic-progress", { 502 | @discourseComputed( 503 | "postStream.loaded", 504 | "topic.currentPost", 505 | "postStream.filteredPostsCount", 506 | "topic.qa_enabled" 507 | ) 508 | hideProgress(loaded, currentPost, filteredPostsCount, qaEnabled) { 509 | return ( 510 | qaEnabled || 511 | !loaded || 512 | !currentPost || 513 | (!this.site.mobileView && filteredPostsCount < 2) 514 | ); 515 | }, 516 | 517 | @discourseComputed( 518 | "progressPosition", 519 | "topic.last_read_post_id", 520 | "topic.qa_enabled" 521 | ) 522 | showBackButton(position, lastReadId, qaEnabled) { 523 | if (!lastReadId || qaEnabled) { 524 | return; 525 | } 526 | 527 | const stream = this.get("postStream.stream"); 528 | const readPos = stream.indexOf(lastReadId) || 0; 529 | return readPos < stream.length - 1 && readPos > position; 530 | } 531 | }); 532 | 533 | api.modifyClass("component:topic-navigation", { 534 | _performCheckSize() { 535 | if (!this.element || this.isDestroying || this.isDestroyed) return; 536 | 537 | if (this.get("topic.qa_enabled")) { 538 | const info = this.get("info"); 539 | info.setProperties({ 540 | renderTimeline: false, 541 | renderAdminMenuButton: true 542 | }); 543 | } else { 544 | this._super(...arguments); 545 | } 546 | } 547 | }); 548 | 549 | api.reopenWidget("post", { 550 | html(attrs) { 551 | if (attrs.cloaked) { 552 | return ""; 553 | } 554 | 555 | if (attrs.qa_enabled && !attrs.firstPost) { 556 | attrs.replyToUsername = null; 557 | if (attrs.reply_to_post_number) { 558 | attrs.canCreatePost = false; 559 | api.changeWidgetSetting("post-avatar", "size", "small"); 560 | } else { 561 | attrs.replyCount = null; 562 | api.changeWidgetSetting("post-avatar", "size", "large"); 563 | } 564 | } 565 | 566 | return this.attach("post-article", attrs); 567 | }, 568 | 569 | openCommentCompose() { 570 | this.sendWidgetAction("showComments", this.attrs.id); 571 | this.sendWidgetAction("replyToPost", this.model).then(() => { 572 | next(this, () => { 573 | const composer = api.container.lookup("controller:composer"); 574 | 575 | if (!composer.model.post) { 576 | composer.model.set("post", this.model); 577 | } 578 | }); 579 | }); 580 | } 581 | }); 582 | 583 | function renderParticipants(userFilters, participants) { 584 | if (!participants) { 585 | return; 586 | } 587 | 588 | userFilters = userFilters || []; 589 | return participants.map(p => { 590 | return this.attach("topic-participant", p, { 591 | state: { toggled: userFilters.includes(p.username) } 592 | }); 593 | }); 594 | } 595 | 596 | api.reopenWidget("topic-map-summary", { 597 | html(attrs, state) { 598 | if (attrs.qa_enabled) { 599 | return this.qaMap(attrs, state); 600 | } else { 601 | return this._super(attrs, state); 602 | } 603 | }, 604 | 605 | qaMap(attrs, state) { 606 | const contents = []; 607 | 608 | contents.push( 609 | h("li", [ 610 | h("h4", I18n.t("created_lowercase")), 611 | h("div.topic-map-post.created-at", [ 612 | avatarFor("tiny", { 613 | username: attrs.createdByUsername, 614 | template: attrs.createdByAvatarTemplate, 615 | name: attrs.createdByName 616 | }), 617 | dateNode(attrs.topicCreatedAt) 618 | ]) 619 | ]) 620 | ); 621 | 622 | let lastAnswerUrl = attrs.topicUrl + "/" + attrs.last_answer_post_number; 623 | let postType = attrs.oneToMany ? "one_to_many" : "answer"; 624 | 625 | contents.push( 626 | h( 627 | "li", 628 | h("a", { attributes: { href: lastAnswerUrl } }, [ 629 | h("h4", I18n.t(`last_${postType}_lowercase`)), 630 | h("div.topic-map-post.last-answer", [ 631 | avatarFor("tiny", { 632 | username: attrs.last_answerer.username, 633 | template: attrs.last_answerer.avatar_template, 634 | name: attrs.last_answerer.name 635 | }), 636 | dateNode(attrs.last_answered_at) 637 | ]) 638 | ]) 639 | ) 640 | ); 641 | 642 | contents.push( 643 | h("li", [ 644 | numberNode(attrs.answer_count), 645 | h( 646 | "h4", 647 | I18n.t(`${postType}_lowercase`, { count: attrs.answer_count }) 648 | ) 649 | ]) 650 | ); 651 | 652 | contents.push( 653 | h("li.secondary", [ 654 | numberNode(attrs.topicViews, { className: attrs.topicViewsHeat }), 655 | h("h4", I18n.t("views_lowercase", { count: attrs.topicViews })) 656 | ]) 657 | ); 658 | 659 | contents.push( 660 | h("li.secondary", [ 661 | numberNode(attrs.participantCount), 662 | h("h4", I18n.t("users_lowercase", { count: attrs.participantCount })) 663 | ]) 664 | ); 665 | 666 | if (attrs.topicLikeCount) { 667 | contents.push( 668 | h("li.secondary", [ 669 | numberNode(attrs.topicLikeCount), 670 | h("h4", I18n.t("likes_lowercase", { count: attrs.topicLikeCount })) 671 | ]) 672 | ); 673 | } 674 | 675 | if (attrs.topicLinkLength > 0) { 676 | contents.push( 677 | h("li.secondary", [ 678 | numberNode(attrs.topicLinkLength), 679 | h("h4", I18n.t("links_lowercase", { count: attrs.topicLinkLength })) 680 | ]) 681 | ); 682 | } 683 | 684 | if ( 685 | state.collapsed && 686 | attrs.topicPostsCount > 2 && 687 | attrs.participants.length > 0 688 | ) { 689 | const participants = renderParticipants.call( 690 | this, 691 | attrs.userFilters, 692 | attrs.participants.slice(0, 3) 693 | ); 694 | contents.push(h("li.avatars", participants)); 695 | } 696 | 697 | const nav = h( 698 | "nav.buttons", 699 | this.attach("button", { 700 | title: "topic.toggle_information", 701 | icon: state.collapsed ? "chevron-down" : "chevron-up", 702 | action: "toggleMap", 703 | className: "btn" 704 | }) 705 | ); 706 | 707 | return [nav, h("ul.clearfix", contents)]; 708 | } 709 | }); 710 | 711 | api.reopenWidget("post-admin-menu", { 712 | html() { 713 | const result = this._super(...arguments); 714 | 715 | if (this.attrs.qa_enabled && this.attrs.comment) { 716 | const button = { 717 | label: "qa.set_as_answer", 718 | action: "setAsAnswer", 719 | className: "popup-menu-button", 720 | secondaryAction: "closeAdminMenu" 721 | }; 722 | 723 | result.children.push(this.attach("post-admin-menu-button", button)); 724 | } 725 | 726 | return result; 727 | }, 728 | 729 | setAsAnswer() { 730 | const post = this.findAncestorModel(); 731 | 732 | setAsAnswer(post).then(result => { 733 | location.reload(); 734 | }); 735 | } 736 | }); 737 | } 738 | 739 | export default { 740 | name: "qa-edits", 741 | initialize(container) { 742 | const siteSettings = container.lookup("site-settings:main"); 743 | 744 | if (!siteSettings.qa_enabled) return; 745 | 746 | withPluginApi("0.8.12", initPlugin); 747 | } 748 | }; 749 | --------------------------------------------------------------------------------