From 96d2c5069b74d5797c03669efca078ac7bd0bc04 Mon Sep 17 00:00:00 2001
From: Robin Ward <robin.ward@gmail.com>
Date: Fri, 10 Apr 2015 17:00:50 -0400
Subject: [PATCH] Interface for reviewing queued posts

---
 .../discourse/controllers/queued-posts.js.es6 | 16 ++++++++++
 .../discourse/helpers/cook-text.js.es6        |  6 ++++
 .../discourse/routes/app-route-map.js.es6     |  2 ++
 .../discourse/routes/queued-posts.js.es6      |  8 +++++
 .../discourse/templates/queued-posts.hbs      | 30 +++++++++++++++++++
 .../discourse/templates/site-map.hbs          |  4 +--
 app/assets/stylesheets/desktop.scss           |  1 +
 .../stylesheets/desktop/queued-posts.scss     | 20 +++++++++++++
 app/controllers/admin/admin_controller.rb     |  7 -----
 app/controllers/application_controller.rb     |  4 +++
 app/controllers/queued_posts_controller.rb    | 20 +++++++++++++
 app/models/queued_post.rb                     | 10 +++++--
 app/serializers/queued_post_serializer.rb     | 14 +++++++++
 config/locales/client.en.yml                  |  3 ++
 config/routes.rb                              |  3 ++
 lib/new_post_manager.rb                       | 12 ++++++--
 lib/new_post_result.rb                        |  1 +
 spec/components/new_post_manager_spec.rb      |  8 +++--
 .../queued_posts_controller_spec.rb           | 28 +++++++++++++++++
 .../acceptance/queued-posts-test.js.es6       | 29 ++++++++++++++++++
 .../helpers/create-pretender.js.es6           | 10 +++++++
 21 files changed, 219 insertions(+), 17 deletions(-)
 create mode 100644 app/assets/javascripts/discourse/controllers/queued-posts.js.es6
 create mode 100644 app/assets/javascripts/discourse/helpers/cook-text.js.es6
 create mode 100644 app/assets/javascripts/discourse/routes/queued-posts.js.es6
 create mode 100644 app/assets/javascripts/discourse/templates/queued-posts.hbs
 create mode 100644 app/assets/stylesheets/desktop/queued-posts.scss
 create mode 100644 app/controllers/queued_posts_controller.rb
 create mode 100644 app/serializers/queued_post_serializer.rb
 create mode 100644 spec/controllers/queued_posts_controller_spec.rb
 create mode 100644 test/javascripts/acceptance/queued-posts-test.js.es6

diff --git a/app/assets/javascripts/discourse/controllers/queued-posts.js.es6 b/app/assets/javascripts/discourse/controllers/queued-posts.js.es6
new file mode 100644
index 000000000..a597c2cb4
--- /dev/null
+++ b/app/assets/javascripts/discourse/controllers/queued-posts.js.es6
@@ -0,0 +1,16 @@
+export default Ember.Controller.extend({
+
+  actions: {
+    approve(post) {
+      post.update({ state: 'approved' }).then(() => {
+        this.get('model').removeObject(post);
+      });
+    },
+
+    reject(post) {
+      post.update({ state: 'rejected' }).then(() => {
+        this.get('model').removeObject(post);
+      });
+    }
+  }
+});
diff --git a/app/assets/javascripts/discourse/helpers/cook-text.js.es6 b/app/assets/javascripts/discourse/helpers/cook-text.js.es6
new file mode 100644
index 000000000..c7acadce1
--- /dev/null
+++ b/app/assets/javascripts/discourse/helpers/cook-text.js.es6
@@ -0,0 +1,6 @@
+import registerUnbound from 'discourse/helpers/register-unbound';
+
+registerUnbound('cook-text', function(text) {
+  return new Handlebars.SafeString(Discourse.Markdown.cook(text));
+});
+
diff --git a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 b/app/assets/javascripts/discourse/routes/app-route-map.js.es6
index b866d99a2..922183aeb 100644
--- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6
+++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6
@@ -93,4 +93,6 @@ export default function() {
   this.resource('badges', function() {
     this.route('show', {path: '/:id/:slug'});
   });
+
+  this.resource('queued-posts', { path: '/queued-posts' });
 }
diff --git a/app/assets/javascripts/discourse/routes/queued-posts.js.es6 b/app/assets/javascripts/discourse/routes/queued-posts.js.es6
new file mode 100644
index 000000000..58b28dd8f
--- /dev/null
+++ b/app/assets/javascripts/discourse/routes/queued-posts.js.es6
@@ -0,0 +1,8 @@
+import DiscourseRoute from 'discourse/routes/discourse';
+
+export default DiscourseRoute.extend({
+  model() {
+    return this.store.find('queuedPost', {status: 'new'});
+  }
+});
+
diff --git a/app/assets/javascripts/discourse/templates/queued-posts.hbs b/app/assets/javascripts/discourse/templates/queued-posts.hbs
new file mode 100644
index 000000000..2119de912
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/queued-posts.hbs
@@ -0,0 +1,30 @@
+<div class='container'>
+  <div class='queued-posts'>
+    {{#each post in model}}
+    <div class='queued-post'>
+      {{#if post.title}}
+      <h4 class='title'>{{post.title}}</h4>
+      {{/if}}
+      <div class='poster'>
+        {{avatar post.user imageSize="large"}}
+      </div>
+      <div class='cooked'>
+        <div class='names'>
+          <span class='username'>{{post.user.username}}</span>
+        </div>
+        <div class='clearfix'></div>
+
+        {{{cook-text post.raw}}}
+
+        <div class='queue-controls'>
+          {{d-button action="approve" actionParam=post label="queue.approve" icon="check" class="btn-primary approve"}}
+          {{d-button action="reject" actionParam=post label="queue.reject" icon="times" class="btn-warning reject"}}
+        </div>
+      </div>
+      <div class='clearfix'></div>
+    </div>
+    {{else}}
+      <p>{{i18n "queue.none"}}</p>
+    {{/each}}
+  </div>
+</div>
diff --git a/app/assets/javascripts/discourse/templates/site-map.hbs b/app/assets/javascripts/discourse/templates/site-map.hbs
index 0bbe1dd96..46e9080df 100644
--- a/app/assets/javascripts/discourse/templates/site-map.hbs
+++ b/app/assets/javascripts/discourse/templates/site-map.hbs
@@ -28,12 +28,12 @@
 
     {{#if currentUser.staff}}
       <li>
-        <a href="/queued-posts">
+        {{#link-to 'queued-posts'}}
           {{i18n "queue.title"}}
           {{#if currentUser.post_queue_new_count}}
           <span class='badge-notification flagged-posts'>{{currentUser.post_queue_new_count}}</span>
           {{/if}}
-        </a>
+        {{/link-to}}
       </li>
     {{/if}}
 
diff --git a/app/assets/stylesheets/desktop.scss b/app/assets/stylesheets/desktop.scss
index abeab1492..426146d3b 100644
--- a/app/assets/stylesheets/desktop.scss
+++ b/app/assets/stylesheets/desktop.scss
@@ -16,6 +16,7 @@
 @import "desktop/upload";
 @import "desktop/user";
 @import "desktop/history";
+@import "desktop/queued-posts";
 
 /* These files doesn't actually exist, they are injected by DiscourseSassImporter. */
 
diff --git a/app/assets/stylesheets/desktop/queued-posts.scss b/app/assets/stylesheets/desktop/queued-posts.scss
new file mode 100644
index 000000000..50955c997
--- /dev/null
+++ b/app/assets/stylesheets/desktop/queued-posts.scss
@@ -0,0 +1,20 @@
+.queued-posts {
+  .queued-post {
+    padding: 1em 0;
+
+    .poster {
+      width: 70px;
+      float: left;
+    }
+    .cooked {
+      width: $topic-body-width;
+      float: left;
+    }
+    h4.title {
+      margin-bottom: 1em;
+    }
+
+    border-bottom: 1px solid darken(scale-color-diff(), 10%);
+  }
+}
+
diff --git a/app/controllers/admin/admin_controller.rb b/app/controllers/admin/admin_controller.rb
index 77d12479c..30785689e 100644
--- a/app/controllers/admin/admin_controller.rb
+++ b/app/controllers/admin/admin_controller.rb
@@ -7,11 +7,4 @@ class Admin::AdminController < ApplicationController
     render nothing: true
   end
 
-  protected
-
-  # this is not really necessary cause the routes are secure
-  def ensure_staff
-    raise Discourse::InvalidAccess.new unless current_user.staff?
-  end
-
 end
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 3b76392c2..7ee38765d 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -371,6 +371,10 @@ class ApplicationController < ActionController::Base
       raise Discourse::NotLoggedIn.new unless current_user.present?
     end
 
+    def ensure_staff
+      raise Discourse::InvalidAccess.new unless current_user && current_user.staff?
+    end
+
     def redirect_to_login_if_required
       return if current_user || (request.format.json? && api_key_valid?)
 
diff --git a/app/controllers/queued_posts_controller.rb b/app/controllers/queued_posts_controller.rb
new file mode 100644
index 000000000..e7dd20d79
--- /dev/null
+++ b/app/controllers/queued_posts_controller.rb
@@ -0,0 +1,20 @@
+require_dependency 'queued_post_serializer'
+
+class QueuedPostsController < ApplicationController
+
+  before_filter :ensure_staff
+
+  def index
+    state = QueuedPost.states[(params[:state] || 'new').to_sym]
+    state ||= QueuedPost.states[:new]
+
+    @queued_posts = QueuedPost.where(state: state)
+    render_serialized(@queued_posts, QueuedPostSerializer, root: :queued_posts)
+  end
+
+  def update
+    qp = QueuedPost.where(id: params[:id]).first
+    render_serialized(qp, QueuedPostSerializer, root: :queued_posts)
+  end
+
+end
diff --git a/app/models/queued_post.rb b/app/models/queued_post.rb
index 03d5e352a..8dce4ffee 100644
--- a/app/models/queued_post.rb
+++ b/app/models/queued_post.rb
@@ -30,7 +30,7 @@ class QueuedPost < ActiveRecord::Base
     where(state: states[:new]).count
   end
 
-  def self.publish_new!
+  def self.broadcast_new!
     msg = { post_queue_new_count: QueuedPost.new_count }
     MessageBus.publish('/queue_counts', msg, user_ids: User.staff.pluck(:id))
   end
@@ -60,10 +60,14 @@ class QueuedPost < ActiveRecord::Base
     created_post
   end
 
+  def self.all_attributes_for(queue)
+    [QueuedPost.attributes_by_queue[:base], QueuedPost.attributes_by_queue[queue.to_sym]].flatten.compact
+  end
+
   private
 
     def post_attributes
-      [QueuedPost.attributes_by_queue[:base], QueuedPost.attributes_by_queue[queue.to_sym]].flatten.compact
+      QueuedPost.all_attributes_for(queue)
     end
 
     def change_to!(state, changed_by)
@@ -83,7 +87,7 @@ class QueuedPost < ActiveRecord::Base
       updates.each {|k, v| send("#{k}=", v) }
       changes_applied
 
-      QueuedPost.publish_new!
+      QueuedPost.broadcast_new!
     end
 
 end
diff --git a/app/serializers/queued_post_serializer.rb b/app/serializers/queued_post_serializer.rb
new file mode 100644
index 000000000..ed9b9a700
--- /dev/null
+++ b/app/serializers/queued_post_serializer.rb
@@ -0,0 +1,14 @@
+class QueuedPostSerializer < ApplicationSerializer
+  attributes :id,
+             :queue,
+             :user_id,
+             :state,
+             :topic_id,
+             :approved_by_id,
+             :rejected_by_id,
+             :raw,
+             :post_options,
+             :created_at
+
+  has_one :user, serializer: BasicUserSerializer, embed: :object
+end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index cbfa19f3a..d27df4732 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -226,7 +226,10 @@ en:
         placeholder: "type the topic title here"
 
     queue:
+      approve: 'Approve Post'
+      reject: 'Reject Post'
       title: "Needs Approval"
+      none: "There are no posts to review."
 
       approval:
         title: "Post Needs Approval"
diff --git a/config/routes.rb b/config/routes.rb
index 731838d2e..17a9c307f 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -454,6 +454,9 @@ Discourse::Application.routes.draw do
   get "/posts/:id/raw-email" => "posts#raw_email"
   get "raw/:topic_id(/:post_number)" => "posts#markdown_num"
 
+  resources :queued_posts, constraints: StaffConstraint.new
+  get 'queued-posts' => 'queued_posts#index'
+
   resources :invites do
     collection do
       get "upload" => "invites#check_csv_chunk"
diff --git a/lib/new_post_manager.rb b/lib/new_post_manager.rb
index f892dcbb6..d649cdf0a 100644
--- a/lib/new_post_manager.rb
+++ b/lib/new_post_manager.rb
@@ -21,7 +21,7 @@ class NewPostManager
 
   def initialize(user, args)
     @user = user
-    @args = args
+    @args = args.delete_if {|_, v| v.nil?}
   end
 
   def perform
@@ -41,10 +41,16 @@ class NewPostManager
   def enqueue(queue)
     result = NewPostResult.new(:enqueued)
     enqueuer = PostEnqueuer.new(@user, queue)
-    post = enqueuer.enqueue(@args)
 
-    QueuedPost.publish_new! if post && post.errors.empty?
+    queued_args = {post_options: @args.dup}
+    queued_args[:raw] = queued_args[:post_options].delete(:raw)
+    queued_args[:topic_id] = queued_args[:post_options].delete(:topic_id)
 
+    post = enqueuer.enqueue(queued_args)
+
+    QueuedPost.broadcast_new! if post && post.errors.empty?
+
+    result.queued_post = post
     result.check_errors_from(enqueuer)
     result
   end
diff --git a/lib/new_post_result.rb b/lib/new_post_result.rb
index 1e8bdffb0..a14f0458a 100644
--- a/lib/new_post_result.rb
+++ b/lib/new_post_result.rb
@@ -5,6 +5,7 @@ class NewPostResult
 
   attr_reader :action
   attr_accessor :post
+  attr_accessor :queued_post
 
   def initialize(action, success=false)
     @action = action
diff --git a/spec/components/new_post_manager_spec.rb b/spec/components/new_post_manager_spec.rb
index 78bc12506..48d81bd0f 100644
--- a/spec/components/new_post_manager_spec.rb
+++ b/spec/components/new_post_manager_spec.rb
@@ -32,7 +32,7 @@ describe NewPostManager do
         result
       end
 
-      @queue_handler = -> (manager) { manager.args[:raw] =~ /queue me/ ? manager.enqueue('test') : nil }
+      @queue_handler = -> (manager) { manager.args[:raw] =~ /queue me/ ? manager.enqueue('new_topic') : nil }
 
       NewPostManager.add_handler(&@counter_handler)
       NewPostManager.add_handler(&@queue_handler)
@@ -56,10 +56,14 @@ describe NewPostManager do
     end
 
     it "calls custom enqueuing handlers" do
-      manager = NewPostManager.new(topic.user, raw: 'to the handler I say enqueue me!', topic_id: topic.id)
+      manager = NewPostManager.new(topic.user, raw: 'to the handler I say enqueue me!', title: 'this is the title of the queued post')
 
       result = manager.perform
 
+      enqueued = result.queued_post
+
+      expect(enqueued).to be_present
+      expect(enqueued.post_options['title']).to eq('this is the title of the queued post')
       expect(result.action).to eq(:enqueued)
       expect(result).to be_success
       expect(result.post).to be_blank
diff --git a/spec/controllers/queued_posts_controller_spec.rb b/spec/controllers/queued_posts_controller_spec.rb
new file mode 100644
index 000000000..99cbd3834
--- /dev/null
+++ b/spec/controllers/queued_posts_controller_spec.rb
@@ -0,0 +1,28 @@
+require 'spec_helper'
+
+describe QueuedPostsController do
+  context 'without authentication' do
+    it 'fails' do
+      xhr :get, :index
+      expect(response).not_to be_success
+    end
+  end
+
+  context 'as a regular user' do
+    let!(:user) { log_in(:user) }
+    it 'fails' do
+      xhr :get, :index
+      expect(response).not_to be_success
+    end
+  end
+
+  context 'as an admin' do
+    let!(:user) { log_in(:moderator) }
+
+    it 'returns the queued posts' do
+      xhr :get, :index
+      expect(response).to be_success
+    end
+  end
+end
+
diff --git a/test/javascripts/acceptance/queued-posts-test.js.es6 b/test/javascripts/acceptance/queued-posts-test.js.es6
new file mode 100644
index 000000000..33b301790
--- /dev/null
+++ b/test/javascripts/acceptance/queued-posts-test.js.es6
@@ -0,0 +1,29 @@
+import { acceptance } from "helpers/qunit-helpers";
+
+acceptance("Queued Posts", { loggedIn: true });
+
+test("approve a post", () => {
+  visit("/queued-posts");
+
+  andThen(() => {
+    ok(exists('.queued-post'), 'it has posts listed');
+  });
+
+  click('.queued-post:eq(0) button.approve');
+  andThen(() => {
+    ok(!exists('.queued-post'), 'it removes the post');
+  });
+});
+
+test("reject a post", () => {
+  visit("/queued-posts");
+
+  andThen(() => {
+    ok(exists('.queued-post'), 'it has posts listed');
+  });
+
+  click('.queued-post:eq(0) button.reject');
+  andThen(() => {
+    ok(!exists('.queued-post'), 'it removes the post');
+  });
+});
diff --git a/test/javascripts/helpers/create-pretender.js.es6 b/test/javascripts/helpers/create-pretender.js.es6
index d07876fe2..c71f168c4 100644
--- a/test/javascripts/helpers/create-pretender.js.es6
+++ b/test/javascripts/helpers/create-pretender.js.es6
@@ -94,6 +94,16 @@ export default function() {
       return response({});
     });
 
+    this.put('/queued_posts/:queued_post_id', function(request) {
+      return response({ queued_post: {id: request.params.queued_post_id } });
+    });
+
+    this.get('/queued_posts', function() {
+      return response({
+        queued_posts: [{id: 1}]
+      });
+    });
+
     this.post('/session', function(request) {
       const data = parsePostData(request.requestBody);