diff --git a/app/assets/javascripts/articles.coffee b/app/assets/javascripts/articles.coffee new file mode 100644 index 000000000..24f83d18b --- /dev/null +++ b/app/assets/javascripts/articles.coffee @@ -0,0 +1,3 @@ +# Place all the behaviors and hooks related to the matching controller here. +# All this logic will automatically be available in application.js. +# You can use CoffeeScript in this file: http://coffeescript.org/ diff --git a/app/assets/stylesheets/articles.scss b/app/assets/stylesheets/articles.scss new file mode 100644 index 000000000..e77f17a9e --- /dev/null +++ b/app/assets/stylesheets/articles.scss @@ -0,0 +1,3 @@ +// Place all the styles related to the Articles controller here. +// They will automatically be included in application.css. +// You can use Sass (SCSS) here: http://sass-lang.com/ diff --git a/app/assets/stylesheets/posts.scss b/app/assets/stylesheets/posts.scss index 53e70c898..ed8df6017 100644 --- a/app/assets/stylesheets/posts.scss +++ b/app/assets/stylesheets/posts.scss @@ -75,3 +75,12 @@ h1 .badge.is-tag.is-master-tag { margin-bottom: 0; } } + +.post--title { + display: flex; + align-items: center; + + > .badge { + margin-left: 0.5em; + } +} diff --git a/app/controllers/articles_controller.rb b/app/controllers/articles_controller.rb new file mode 100644 index 000000000..b4253e185 --- /dev/null +++ b/app/controllers/articles_controller.rb @@ -0,0 +1,92 @@ +class ArticlesController < ApplicationController + before_action :set_article + before_action :check_article + + def show + if @article.deleted? + check_your_privilege('ViewDeleted', @article) # || return + end + end + + def share + redirect_to article_path(params[:id]) + end + + def edit + check_your_privilege('Edit', @article) + end + + def update + return unless check_your_privilege('Edit', @article) + + PostHistory.post_edited(@article, current_user, before: @article.body_markdown, + after: params[:article][:body_markdown], comment: params[:edit_comment]) + body_rendered = helpers.render_markdown(params[:article][:body_markdown]) + if @article.update(article_params.merge(tags_cache: params[:article][:tags_cache]&.reject { |e| e.to_s.empty? }, + body: body_rendered, last_activity: DateTime.now, + last_activity_by: current_user)) + redirect_to article_path(@article) + else + render :edit + end + end + + def destroy + unless check_your_privilege('Delete', @article, false) + flash[:danger] = 'You must have the Delete privilege to delete posts.' + redirect_to article_path(@article) && return + end + + if @article.deleted + flash[:danger] = "Can't delete a deleted post." + redirect_to article_path(@article) && return + end + + if @article.update(deleted: true, deleted_at: DateTime.now, deleted_by: current_user, + last_activity: DateTime.now, last_activity_by: current_user) + PostHistory.post_deleted(@article, current_user) + else + flash[:danger] = "Can't delete this post right now. Try again later." + end + redirect_to article_path(@article) + end + + def undelete + unless check_your_privilege('Delete', @article, false) + flash[:danger] = 'You must have the Delete privilege to undelete posts.' + redirect_to article_path(@article) && return + end + + unless @article.deleted + flash[:danger] = "Can't undelete an undeleted post." + redirect_to article_path(@article) && return + end + + if @article.update(deleted: false, deleted_at: nil, deleted_by: nil, + last_activity: DateTime.now, last_activity_by: current_user) + PostHistory.post_undeleted(@article, current_user) + else + flash[:danger] = "Can't undelete this article right now. Try again later." + end + redirect_to article_path(@article) + end + + private + + def set_article + @article = Article.find params[:id] + if @article.deleted && !current_user&.has_post_privilege?('ViewDeleted', @article) + not_found + end + end + + def check_article + unless @article.post_type_id == Article.post_type_id + not_found + end + end + + def article_params + params.require(:article).permit(:body_markdown, :title, :tags_cache) + end +end diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb index 1ff980842..915a2c916 100644 --- a/app/controllers/comments_controller.rb +++ b/app/controllers/comments_controller.rb @@ -88,21 +88,10 @@ def check_privilege def comment_link(comment) if comment.post.question? question_path(comment.post, anchor: "comment-#{comment.id}") + elsif comment.post.article? + article_path(comment.post, anchor: "comment-#{comment.id}") else question_path(comment.post.parent, anchor: "comment-#{comment.id}") end end end - -# Provides a custom HTML sanitization interface to use for cleaning up the HTML in questions. -class CommentScrubber < Rails::Html::PermitScrubber - def initialize - super - self.tags = %w[a b i em strong strike del code] - self.attributes = %w[href title] - end - - def skip_node?(node) - node.text? - end -end diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index ed4091c9e..0970ba6e3 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -15,7 +15,8 @@ def new def create @category = Category.find(params[:category_id]) - @post = Post.new(post_params.merge(category: @category, user: current_user, post_type_id: params[:post_type_id], + @post = Post.new(post_params.merge(category: @category, user: current_user, + post_type_id: params[:post][:post_type_id] || params[:post_type_id], body: helpers.render_markdown(params[:post][:body_markdown]))) if @category.min_trust_level.present? && @category.min_trust_level > current_user.trust_level @@ -25,7 +26,7 @@ def create end if @post.save - redirect_to question_path(@post) + redirect_to helpers.generic_show_link(@post) else render :new, status: 400 end diff --git a/app/controllers/questions_controller.rb b/app/controllers/questions_controller.rb index cf9ce5ba2..8d478f8be 100644 --- a/app/controllers/questions_controller.rb +++ b/app/controllers/questions_controller.rb @@ -65,7 +65,7 @@ def update PostHistory.post_edited(@question, current_user, before: @question.body_markdown, after: params[:question][:body_markdown], comment: params[:edit_comment]) body_rendered = helpers.render_markdown(params[:question][:body_markdown]) - if @question.update(question_params.merge(tags_cache: params[:question][:tags_cache]&.reject(&:empty?), + if @question.update(question_params.merge(tags_cache: params[:question][:tags_cache]&.reject { |e| e.to_s.empty? }, body: body_rendered, last_activity: DateTime.now, last_activity_by: current_user)) redirect_to url_for(controller: :questions, action: :show, id: @question.id) @@ -191,11 +191,15 @@ def question_params def set_question @question = Question.find params[:id] rescue - if current_user.has_privilege?('ViewDeleted') + if current_user&.has_privilege?('ViewDeleted') @question ||= Question.unscoped.find params[:id] end if @question.nil? - render template: 'errors/not_found', status: 404 + not_found + return + end + unless @question.post_type_id == Question.post_type_id + not_found end end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 78fdcce45..ef7d04e6b 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -75,4 +75,41 @@ def strip_markdown(markdown) markdown end + + def generic_share_link(post) + case post.post_type_id + when Question.post_type_id + share_question_url(post) + when Answer.post_type_id + share_answer_url(qid: post.parent_id, id: post.id) + when Article.post_type_id + share_article_url(post) + else + '#' + end + end + + def generic_edit_link(post) + case post.post_type_id + when Question.post_type_id + edit_question_url(post) + when Answer.post_type_id + edit_answer_url(post) + when Article.post_type_id + edit_article_url(post) + else + '#' + end + end + + def generic_show_link(post) + case post.post_type_id + when Question.post_type_id + question_url(post) + when Article.post_type_id + article_url(post) + else + '#' + end + end end diff --git a/app/helpers/articles_helper.rb b/app/helpers/articles_helper.rb new file mode 100644 index 000000000..296827759 --- /dev/null +++ b/app/helpers/articles_helper.rb @@ -0,0 +1,2 @@ +module ArticlesHelper +end diff --git a/app/helpers/categories_helper.rb b/app/helpers/categories_helper.rb index bb87154d1..cfb31edf2 100644 --- a/app/helpers/categories_helper.rb +++ b/app/helpers/categories_helper.rb @@ -8,7 +8,8 @@ def active?(category) def expandable? (defined?(@category) && !current_page?(new_category_path)) || (defined?(@post) && !@post.category.nil?) || - (defined?(@question) && !@question.category.nil?) + (defined?(@question) && !@question.category.nil?) || + (defined?(@article) && !@article.category.nil?) end def current_category @@ -18,6 +19,8 @@ def current_category @post.category elsif defined?(@question) && !@question.category.nil? @question.category + elsif defined?(@article) && !@article.category.nil? + @article.category end end end diff --git a/app/helpers/comments_helper.rb b/app/helpers/comments_helper.rb index 0ec9ca5f2..1feb966f4 100644 --- a/app/helpers/comments_helper.rb +++ b/app/helpers/comments_helper.rb @@ -1,2 +1,14 @@ module CommentsHelper end + +class CommentScrubber < Rails::Html::PermitScrubber + def initialize + super + self.tags = %w[a b i em strong strike del code] + self.attributes = %w[href title] + end + + def skip_node?(node) + node.text? + end +end diff --git a/app/helpers/post_types_helper.rb b/app/helpers/post_types_helper.rb new file mode 100644 index 000000000..ddd6bc365 --- /dev/null +++ b/app/helpers/post_types_helper.rb @@ -0,0 +1,12 @@ +module PostTypesHelper + def post_type_badge(type) + icon_class = { + 'Question' => 'fas fa-question', + 'Article' => 'fas fa-newspaper' + }[type] + tag.span class: 'badge is-tag is-filled is-muted' do + tag.i(class: icon_class) + ' ' + + tag.span(type) + end + end +end diff --git a/app/models/article.rb b/app/models/article.rb new file mode 100644 index 000000000..f5cb2f4d5 --- /dev/null +++ b/app/models/article.rb @@ -0,0 +1,7 @@ +class Article < Post + default_scope { where(post_type_id: Article.post_type_id) } + + def self.post_type_id + PostType.mapping['Article'] + end +end diff --git a/app/models/post.rb b/app/models/post.rb index 036a49311..601c7189e 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -23,15 +23,15 @@ class Post < ApplicationRecord validates :body, presence: true, length: { minimum: 30, maximum: 30_000 } validates :doc_slug, uniqueness: { scope: [:community_id] }, if: -> { doc_slug.present? } - validates :title, :body, :tags_cache, presence: true, if: :question? - validate :tags_in_tag_set, if: :question? - validate :maximum_tags, if: :question? - validate :maximum_tag_length, if: :question? - validate :no_spaces_in_tags, if: :question? - validate :stripped_minimum, if: :question? + validates :title, :body, :tags_cache, presence: true, if: -> { question? || article? } + validate :tags_in_tag_set, if: -> { question? || article? } + validate :maximum_tags, if: -> { question? || article? } + validate :maximum_tag_length, if: -> { question? || article? } + validate :no_spaces_in_tags, if: -> { question? || article? } + validate :stripped_minimum, if: -> { question? || article? } validate :category_allows_post_type validate :license_available - validate :required_tags?, if: -> { post_type_id == Question.post_type_id } + validate :required_tags?, if: -> { question? || article? } scope :undeleted, -> { where(deleted: false) } scope :deleted, -> { where(deleted: true) } @@ -42,8 +42,8 @@ class Post < ApplicationRecord after_save :modify_author_reputation after_save :copy_last_activity_to_parent after_save :break_description_cache - after_save :update_category_activity, if: :question? - before_validation :update_tag_associations, if: :question? + after_save :update_category_activity, if: -> { question? || article? } + before_validation :update_tag_associations, if: -> { question? || article? } after_create :create_initial_revision after_create :add_license_if_nil @@ -53,7 +53,7 @@ def self.search(term) # Double-define: initial definitions are less efficient, so if we have a record of the post type we'll # override them later with more efficient methods. - ['Question', 'Answer', 'PolicyDoc', 'HelpDoc'].each do |pt| + ['Question', 'Answer', 'PolicyDoc', 'HelpDoc', 'Article'].each do |pt| define_method "#{pt.underscore}?" do post_type_id == pt.constantize.post_type_id end diff --git a/app/models/vote.rb b/app/models/vote.rb index fe5eb01fa..a94767d0b 100644 --- a/app/models/vote.rb +++ b/app/models/vote.rb @@ -17,7 +17,8 @@ class Vote < ApplicationRecord def self.total_rep_change(col) col = col.includes(:post) settings = SiteSetting.where(name: ['QuestionUpVoteRep', 'QuestionDownVoteRep', - 'AnswerUpVoteRep', 'AnswerDownVoteRep']) + 'AnswerUpVoteRep', 'AnswerDownVoteRep', + 'ArticleUpVoteRep', 'ArticleDownVoteRep']) .map { |ss| [ss.name, ss.value] }.to_h rep_changes = PostType.mapping.map do |k, v| vote_types = { 1 => 'Up', -1 => 'Down' } @@ -45,7 +46,9 @@ def rep_change(direction) [post_type_ids['Question'], 1] => 'QuestionUpVoteRep', [post_type_ids['Question'], -1] => 'QuestionDownVoteRep', [post_type_ids['Answer'], 1] => 'AnswerUpVoteRep', - [post_type_ids['Answer'], -1] => 'AnswerDownVoteRep' + [post_type_ids['Answer'], -1] => 'AnswerDownVoteRep', + [post_type_ids['Article'], 1] => 'ArticleUpVoteRep', + [post_type_ids['Article'], -1] => 'ArticleDownVoteRep' } rep_change = SiteSetting[setting_names[[post.post_type_id, vote_type]]] || 0 recv_user.update!(reputation: recv_user.reputation + direction * rep_change) diff --git a/app/views/articles/_form.html.erb b/app/views/articles/_form.html.erb new file mode 100644 index 000000000..bb9878bde --- /dev/null +++ b/app/views/articles/_form.html.erb @@ -0,0 +1,47 @@ +<%= render 'posts/markdown_script' %> + +<% if @article.errors.any? %> +
+ The following errors prevented this post from being saved: + +
+<% end %> + +<%= render 'posts/image_upload' %> + +<%= form_for @article, url: edit_article_path(@article) do |f| %> +
+ <%= f.label :title, "Title your post:", class: "form-element" %> + <%= f.text_field :title, class: "form-element" %> +
+ + <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: 'Body' %> + +
+ +
+ <%= f.label :tags_cache, "Tags", class: "form-element" %> +
+ Tags help to categorize posts. Separate them by space. Use hyphens for multiple-word tags. +
+ <%= f.select :tags_cache, options_for_select(@article.tags_cache.map { |t| [t, t] }, selected: @article.tags_cache), + { include_blank: true }, multiple: true, class: "form-element js-tag-select", + data: { tag_set: @article.category.tag_set.id } %> +
+ +
+ <%= label_tag :edit_comment, 'Edit comment', class: "form-element" %> +
+ Describe—if necessary—what you are changing and why you are making this edit. +
+ <%= text_field_tag :edit_comment, params[:edit_comment], class: 'form-element' %> +
+ +
+ <%= f.submit 'Save', class: "button is-filled" %>
+
+<% end %> diff --git a/app/views/articles/edit.html.erb b/app/views/articles/edit.html.erb new file mode 100644 index 000000000..6727a85a7 --- /dev/null +++ b/app/views/articles/edit.html.erb @@ -0,0 +1 @@ +<%= render 'form', is_edit: true %> \ No newline at end of file diff --git a/app/views/articles/show.html.erb b/app/views/articles/show.html.erb new file mode 100644 index 000000000..0e822e298 --- /dev/null +++ b/app/views/articles/show.html.erb @@ -0,0 +1,8 @@ +<% content_for :title, @article.title.truncate(50) %> +<% content_for :description do %> + <% Rails.cache.fetch "posts/#{@article.id}/description" do %> + <%= @article.body_plain[0..74].strip %>... + <% end %> +<% end %> + +<%= render 'posts/expanded', post: @article %> \ No newline at end of file diff --git a/app/views/posts/_article_list.html.erb b/app/views/posts/_article_list.html.erb new file mode 100644 index 000000000..237edc9cf --- /dev/null +++ b/app/views/posts/_article_list.html.erb @@ -0,0 +1,30 @@ +<% active_user = post.last_activity_by || post.user %> +
+
+ <%= post.score %> + score +
+
+
+ <%= link_to post.title, share_article_path(post) %> +
+

+ last activity <%= time_ago_in_words(post.last_activity) %> ago by <%= link_to active_user.username, user_path(active_user) %> +

+
+ <% category = defined?(@category) ? @category : post.category %> + <% if category.display_post_types.reject { |e| e.to_s.empty? }.size > 1 %> + <%= post_type_badge(post.post_type.name) %> + <% end %> + <% tag_set = post.tag_set %> + <% required_ids = category&.required_tag_ids %> + <% topic_ids = category&.topic_tag_ids %> + <% category_sort_tags(post.tags, required_ids, topic_ids).each do |tag| %> + <% required = required_ids&.include? tag.id %> + <% topic = topic_ids&.include? tag.id %> + <%= link_to tag.name, questions_tagged_path(tag_set: tag_set.id, tag: tag.name), + class: "badge is-tag #{required ? 'is-filled' : ''} #{topic ? 'is-outlined' : ''}" %> + <% end %> +
+
+
diff --git a/app/views/posts/_expanded.html.erb b/app/views/posts/_expanded.html.erb index 7c40682cd..7e6c076f6 100644 --- a/app/views/posts/_expanded.html.erb +++ b/app/views/posts/_expanded.html.erb @@ -1,13 +1,16 @@ <% is_question = post.post_type_id == Question.post_type_id %> +<% is_top_level = post.parent_id.nil? %> +<% has_tags = is_top_level && !post.tag_ids.empty? %> -
- <% if is_question %> +
+ <% if is_top_level %>

- <% if post.meta? %> - meta - <% end %> <%= post.title %> - <%= post.closed ? "[closed]" : "" %> + <%= is_question && post.closed ? "[closed]" : "" %> + <% category = defined?(@category) ? @category : post.category %> + <% if category.display_post_types.reject { |e| e.to_s.empty? }.size > 1 %> + <%= post_type_badge(post.post_type.name) %> + <% end %>

<% end %> @@ -95,7 +98,7 @@
- <% if is_question %> + <% if has_tags %>
<% tag_set = post.tag_set %> <% required_ids = post.category&.required_tag_ids %> @@ -121,18 +124,18 @@
<%= link_to 'history', post_history_path(post) %> · - <%= link_to 'edit', is_question ? edit_question_path(post) : edit_answer_path(post) %> · - <%= link_to 'permalink', is_question ? share_question_path(post) : share_answer_path(qid: post.parent_id, id: post.id) %> · + <%= link_to 'edit', generic_edit_link(post) %> · + <%= link_to 'permalink', generic_share_link(post) %> · <% if is_question && !post.closed %> close · <% elsif is_question && post.closed %> <%= link_to 'reopen', reopen_question_path(post), method: :post, class: 'reopen-question' %> · <% end %> <% if !post.deleted %> - <%= link_to 'delete', url_for(controller: is_question ? :questions : :answers, action: :destroy, id: post.id), + <%= link_to 'delete', url_for(controller: post.post_type.name.pluralize.downcase.to_sym, action: :destroy, id: post.id), method: :delete, data: { confirm: 'Are you sure you want to delete this post?' }, class: "is-red" %> <% else %> - <%= link_to 'undelete', url_for(controller: is_question ? :questions : :answers, action: :undelete, id: post.id), + <%= link_to 'undelete', url_for(controller: post.post_type.name.pluralize.downcase.to_sym, action: :undelete, id: post.id), method: :post, data: { confirm: 'Undelete this question, making it visible to regular users?' }, class: "is-red" %> <% end %> · flag diff --git a/app/views/posts/_form.html.erb b/app/views/posts/_form.html.erb index 7bc27bc87..6e71b2d99 100644 --- a/app/views/posts/_form.html.erb +++ b/app/views/posts/_form.html.erb @@ -1,3 +1,5 @@ +<% with_post_type ||= false %> + <% content_for :head do %> <%= render 'posts/markdown_script' %> <% end %> @@ -25,7 +27,20 @@ <%= form_for @post, url: submit_path, html: { class: 'has-margin-top-4' } do |f| %> <%= f.hidden_field :category_id %> - <%= f.hidden_field :post_type_id %> + + <% if with_post_type %> +
+ <%= f.label :post_type_id, 'Post type', class: 'form-element' %> + What kind of post is this? Questions can have answers; articles only have comments. + <% ids = @category.display_post_types.reject { |e| e.to_s.empty? } %> + <% post_types = PostType.where(id: ids) %> + <% opts = post_types.map { |pt| [pt.name, pt.id] } %> + <%= f.select :post_type_id, options_for_select(opts, selected: @post.post_type_id), + { include_blank: true }, class: 'form-element' %> +
+ <% else %> + <%= f.hidden_field :post_type_id %> + <% end %> <%= render 'shared/body_field', f: f, field_name: :body_markdown, field_label: 'Body' %> diff --git a/app/views/posts/_list.html.erb b/app/views/posts/_list.html.erb index 57312cf0c..e72067a30 100644 --- a/app/views/posts/_list.html.erb +++ b/app/views/posts/_list.html.erb @@ -1,5 +1,5 @@ <% is_question = post.post_type_id == Question.post_type_id %> -<% is_meta = (is_question && post.meta?) || (!is_question && post.parent.meta?) %> +<% is_meta = (is_question && post.meta?) || (!is_question && post.parent&.meta?) %> <% active_user = post.last_activity_by || post.user %>
@@ -25,10 +25,14 @@ last activity <%= time_ago_in_words(post.last_activity) %> ago by <%= link_to active_user.username, user_path(active_user) %>

+ <% category = defined?(@category) ? @category : post.category %> + <% if category.display_post_types.reject { |e| e.to_s.empty? }.size > 1 %> + <%= post_type_badge(post.post_type.name) %> + <% end %> <% if is_question %> <% tag_set = post.tag_set %> - <% required_ids = defined?(@category) ? @category&.required_tag_ids : post.category&.required_tag_ids %> - <% topic_ids = defined?(@category) ? @category&.topic_tag_ids : post.category&.topic_tag_ids %> + <% required_ids = category&.required_tag_ids %> + <% topic_ids = category&.topic_tag_ids %> <% category_sort_tags(post.tags, required_ids, topic_ids).each do |tag| %> <% required = required_ids&.include? tag.id %> <% topic = topic_ids&.include? tag.id %> diff --git a/app/views/posts/_type_agnostic.html.erb b/app/views/posts/_type_agnostic.html.erb index 9fb034ba7..2ac0c70f6 100644 --- a/app/views/posts/_type_agnostic.html.erb +++ b/app/views/posts/_type_agnostic.html.erb @@ -3,7 +3,8 @@ 'Question' => 'posts/list', 'Answer' => 'posts/list', 'PolicyDoc' => 'posts/document', - 'HelpDoc' => 'posts/document' + 'HelpDoc' => 'posts/document', + 'Article' => 'posts/article_list' } %> <%= render post_types_views[post.post_type.name], post: post %> \ No newline at end of file diff --git a/app/views/posts/new.html.erb b/app/views/posts/new.html.erb index 09a9d727b..1db9ccf3f 100644 --- a/app/views/posts/new.html.erb +++ b/app/views/posts/new.html.erb @@ -1,4 +1,5 @@

New Post in <%= @category.name %>

Not where you meant to post? See <%= link_to 'Categories', categories_path %>

-<%= render 'form', submit_path: create_post_path(@category.id) %> \ No newline at end of file +<%= render 'form', with_post_type: @category.display_post_types.reject { |e| e.to_s.empty? }.size > 1, + submit_path: create_post_path(@category.id) %> \ No newline at end of file diff --git a/app/views/questions/tagged.html.erb b/app/views/questions/tagged.html.erb index 63bfaafc4..2aa7fe81e 100644 --- a/app/views/questions/tagged.html.erb +++ b/app/views/questions/tagged.html.erb @@ -4,7 +4,7 @@
<% @questions.each do |question| %> - <%= render 'posts/list', post: question %> + <%= render 'posts/type_agnostic', post: question %> <% end %>

diff --git a/config/routes.rb b/config/routes.rb index b7efba506..af9ed4624 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -68,6 +68,14 @@ post 'questions/:id/close', to: 'questions#close', as: :close_question post 'questions/:id/reopen', to: 'questions#reopen', as: :reopen_question + scope 'articles' do + get ':id', to: 'articles#show', as: :article + get ':id/edit', to: 'articles#edit', as: :edit_article + patch ':id/edit', to: 'articles#update', as: :update_article + delete ':id/delete', to: 'articles#destroy', as: :destroy_article + delete ':id/undelete', to: 'articles#undelete', as: :undelete_article + end + get 'posts/:id/history', to: 'post_history#post', as: :post_history get 'posts/search', to: 'search#search', as: :search post 'posts/upload', to: 'posts#upload', as: :upload @@ -135,6 +143,7 @@ get 'q/:id', to: 'posts#share_q', as: :share_question get 'a/:qid/:id', to: 'posts#share_a', as: :share_answer + get 'ar/:id', to: 'articles#share', as: :share_article get 'subscriptions/new/:type', to: 'subscriptions#new', as: :new_subscription post 'subscriptions/new', to: 'subscriptions#create', as: :create_subscription diff --git a/db/seeds/post_types.yml b/db/seeds/post_types.yml index c957d9cfd..ae17f34ee 100644 --- a/db/seeds/post_types.yml +++ b/db/seeds/post_types.yml @@ -1,4 +1,5 @@ - name: Question - name: Answer - name: PolicyDoc -- name: HelpDoc \ No newline at end of file +- name: HelpDoc +- name: Article \ No newline at end of file diff --git a/db/seeds/site_settings.yml b/db/seeds/site_settings.yml index 145846f65..bcf88b9f4 100644 --- a/db/seeds/site_settings.yml +++ b/db/seeds/site_settings.yml @@ -54,6 +54,20 @@ description: > The reputation change to the post's author when an answer is downvoted. +- name: ArticleUpVoteRep + value: 10 + value_type: integer + category: ReputationAndVoting + description: > + The reputation change to the post's author when an article is upvoted. + +- name: ArticleDownVoteRep + value: -2 + value_type: integer + category: ReputationAndVoting + description: > + The reputation change to the post's author when an article is downvoted. + - name: AllowSelfVotes value: false value_type: boolean diff --git a/test/controllers/articles_controller_test.rb b/test/controllers/articles_controller_test.rb new file mode 100644 index 000000000..f431f0687 --- /dev/null +++ b/test/controllers/articles_controller_test.rb @@ -0,0 +1,57 @@ +require 'test_helper' + +class ArticlesControllerTest < ActionController::TestCase + include Devise::Test::ControllerHelpers + + test 'should get show article page' do + get :show, params: { id: posts(:article_one).id } + assert_not_nil assigns(:article) + assert_response(200) + end + + test 'should get show article page with deleted article' do + sign_in users(:deleter) + get :show, params: { id: posts(:deleted_article).id } + assert_not_nil assigns(:article) + assert_response(200) + end + + test 'should prevent unprivileged user seeing deleted post' do + get :show, params: { id: posts(:deleted_article).id } + assert_response 404 + end + + test 'should get edit article page' do + sign_in users(:editor) + get :edit, params: { id: posts(:article_one).id } + assert_not_nil assigns(:article) + assert_response(200) + end + + test 'should update existing article' do + sign_in users(:editor) + patch :update, params: { id: posts(:article_one).id, article: { title: 'ABCDEF GHIJKL MNOPQR', + body_markdown: 'ABCDEF GHIJKL MNOPQR STUVWX YZ', + tags_cache: ['discussion', 'support'] } } + assert_not_nil assigns(:article) + assert_equal ['discussion', 'support'], assigns(:article).tags_cache + assert_equal ['discussion', 'support'], assigns(:article).tags.map(&:name) + assert_response(302) + end + + test 'should mark article deleted' do + sign_in users(:deleter) + delete :destroy, params: { id: posts(:article_one).id } + assert_not_nil assigns(:article) + assert_equal true, assigns(:article).deleted + assert_response(302) + end + + test 'should mark article undeleted' do + sign_in users(:deleter) + delete :undelete, params: { id: posts(:deleted_article).id } + assert_not_nil assigns(:article) + assert_equal false, assigns(:article).deleted + assert_response(302) + end +end diff --git a/test/controllers/moderator_controller_test.rb b/test/controllers/moderator_controller_test.rb index 9350bc885..8528ecc96 100644 --- a/test/controllers/moderator_controller_test.rb +++ b/test/controllers/moderator_controller_test.rb @@ -9,46 +9,6 @@ class ModeratorControllerTest < ActionController::TestCase assert_response(200) end - test 'should get recently deleted questions' do - sign_in users(:moderator) - get :recently_deleted_questions - assert_not_nil assigns(:questions) - assigns(:questions).each do |question| - assert_equal true, question.deleted - end - assert_response(200) - end - - test 'should get recently deleted answers' do - sign_in users(:moderator) - get :recently_deleted_answers - assert_not_nil assigns(:answers) - assigns(:answers).each do |answer| - assert_equal true, answer.deleted - end - assert_response(200) - end - - test 'should get recently undeleted questions' do - sign_in users(:moderator) - get :recently_undeleted_questions - assert_not_nil assigns(:questions) - assigns(:questions).each do |question| - assert_equal false, question.deleted - end - assert_response(200) - end - - test 'should get recently undeleted answers' do - sign_in users(:moderator) - get :recently_undeleted_answers - assert_not_nil assigns(:answers) - assigns(:answers).each do |answer| - assert_equal false, answer.deleted - end - assert_response(200) - end - test 'should require authentication to access pages' do sign_out :user [:index, :recently_deleted_answers, :recently_deleted_questions, :recently_undeleted_answers, diff --git a/test/controllers/questions_controller_test.rb b/test/controllers/questions_controller_test.rb index 32cf644c5..9ca6af0f0 100644 --- a/test/controllers/questions_controller_test.rb +++ b/test/controllers/questions_controller_test.rb @@ -189,4 +189,10 @@ class QuestionsControllerTest < ActionController::TestCase assert_not_nil flash[:danger] assert_response(302) end + + test 'should prevent using questions routes for articles' do + sign_in users(:deleter) # deliberate; catch ViewDeleted using unscoped + get :show, params: { id: posts(:article_one).id } + assert_response 404 + end end diff --git a/test/fixtures/categories.yml b/test/fixtures/categories.yml index 53bae5c6f..df944e28a 100644 --- a/test/fixtures/categories.yml +++ b/test/fixtures/categories.yml @@ -6,9 +6,11 @@ main: short_wiki: Main Q&A display_post_types: - <%= Question.post_type_id %> + - <%= Article.post_type_id %> post_types: - question - answer + - article tag_set: main license: cc_by_sa diff --git a/test/fixtures/post_types.yml b/test/fixtures/post_types.yml index 4736a9e20..71c578bf1 100644 --- a/test/fixtures/post_types.yml +++ b/test/fixtures/post_types.yml @@ -4,6 +4,9 @@ question: answer: name: Answer +article: + name: Article + policy_doc: name: PolicyDoc diff --git a/test/fixtures/posts.yml b/test/fixtures/posts.yml index e8b53a4dd..3039cfa85 100644 --- a/test/fixtures/posts.yml +++ b/test/fixtures/posts.yml @@ -147,3 +147,44 @@ help_doc: user: admin community: sample license: cc_by_sa + +article_one: + post_type: article + title: Q1 ABCDEF GHIJKL MNOPQR STUVWX YZ + body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ + body_markdown: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ + tags_cache: + - discussion + - support + - bug + tags: + - discussion + - support + - bug + score: 0 + user: standard_user + community: sample + category: main + license: cc_by_sa + +deleted_article: + post_type: article + title: Q1 ABCDEF GHIJKL MNOPQR STUVWX YZ + body: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ + body_markdown: ABCDEF GHIJKL MNOPQR STUVWX YZ ABCDEF GHIJKL MNOPQR STUVWX YZ + tags_cache: + - discussion + - support + - bug + tags: + - discussion + - support + - bug + score: 0 + user: standard_user + community: sample + category: main + license: cc_by_sa + deleted: true + deleted_at: 2019-01-01T00:00:00.000000Z + deleted_by: admin