├── 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. Ad ogni utente è consentito un voto. Le risposte vengono ordinate per numero di voti. "
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 |
6 | {{input
7 | type="checkbox"
8 | checked=category.custom_fields.qa_enabled
9 | }}
10 |
11 | {{i18n 'category.enable_qa'}}
12 |
13 |
14 |
15 |
16 |
17 | {{input
18 | type="checkbox"
19 | checked=category.custom_fields.qa_one_to_many
20 | }}
21 |
22 | {{i18n 'category.qa_one_to_many'}}
23 |
24 |
25 |
26 |
27 |
28 | {{input
29 | type="checkbox"
30 | checked=category.custom_fields.qa_disable_like_on_answers
31 | }}
32 |
33 | {{i18n 'category.qa_disable_like_on_answers'}}
34 |
35 |
36 |
37 |
38 |
39 | {{input
40 | type="checkbox"
41 | checked=category.custom_fields.qa_disable_like_on_questions
42 | }}
43 |
44 | {{i18n 'category.qa_disable_like_on_questions'}}
45 |
46 |
47 |
48 |
49 |
50 | {{input
51 | type="checkbox"
52 | checked=category.custom_fields.qa_disable_like_on_comments
53 | }}
54 |
55 | {{i18n 'category.qa_disable_like_on_comments'}}
56 |
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. Chaque utilisateur a droit à un vote. Les réponses sont classées par nombre de votes. "
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. Cada usuario solo tiene permitido un voto. Las respuestas están ordenadas según el número de votos recibidos. "
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.\nSinulla on yksi ääni käytössä keskusteluketjua kohden. Vastaukset järjestäytyvät äänien lukumäärän mukaan. Jos muutat mieltäsi, voit perua annetun äänen. "
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 |
54 | משתמש בעל רמת אמון 1 יכול לבצע {{tl1Limit}} הצבעות.
55 | משתמש בעל רמת אמון 2 יכול לבצע {{tl1Limit}} הצבעות.
56 | משתמש בעל רמת אמון 3 יכול לבצע {{tl1Limit}} הצבעות.
57 | משתמש בעל רמת אמון 4 יכול לבצע {{tl1Limit}} הצבעות.
58 | סדר התגובות נקבע לפי כמות ההצבעות
59 | "
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 |
54 | Trust level 1 is allowed {{tl1Limit}} vote.
55 | Trust level 2 is allowed {{tl2Limit}} vote.
56 | Trust level 3 is allowed {{tl3Limit}} vote.
57 | Trust level 4 is allowed {{tl4Limit}} vote.
58 | Responses are ordered by vote count.
59 | "
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 |
54 | Vertrauensstufe 1 hat {{tl1Limit}} Stimme.
55 | Vertrauensstufe 2 hat {{tl2Limit}} Stimmen.
56 | Vertrauensstufe 3 hat {{tl3Limit}} Stimmen.
57 | Vertrauensstufe 4 hat {{tl4Limit}} Stimmen.
58 | Antworten werden nach der Anzahl der Stimmen sortiert.
59 | "
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 |
--------------------------------------------------------------------------------