From dc90c396f2bb997c50b0049f271370495d820ed7 Mon Sep 17 00:00:00 2001
From: Arpit Jalan <arpit@techapj.com>
Date: Wed, 15 Jul 2015 18:24:28 +0530
Subject: [PATCH] FEATURE: manage Permalinks

---
 .../admin/components/permalink-form.js.es6    | 56 +++++++++++++++++++
 .../admin/controllers/admin-permalinks.js.es6 | 36 ++++++++++++
 .../javascripts/admin/models/permalink.js.es6 | 22 ++++++++
 .../admin/routes/admin-permalinks.js.es6      |  9 +++
 .../admin/routes/admin-route-map.js.es6       |  1 +
 .../templates/components/permalink-form.hbs   |  5 ++
 .../javascripts/admin/templates/customize.hbs |  1 +
 .../admin/templates/permalinks.hbs            | 25 +++++++++
 .../admin/templates/permalinks_list_item.hbs  |  7 +++
 .../admin/views/permalinks-list.js.es6        |  8 +++
 .../stylesheets/common/admin/admin_base.scss  | 18 +++++-
 .../admin/permalinks_controller.rb            | 39 +++++++++++++
 app/serializers/permalink_serializer.rb       |  3 +
 config/locales/client.en.yml                  | 13 +++++
 config/routes.rb                              |  3 +
 .../admin/permalinks_controller_spec.rb       | 51 +++++++++++++++++
 16 files changed, 296 insertions(+), 1 deletion(-)
 create mode 100644 app/assets/javascripts/admin/components/permalink-form.js.es6
 create mode 100644 app/assets/javascripts/admin/controllers/admin-permalinks.js.es6
 create mode 100644 app/assets/javascripts/admin/models/permalink.js.es6
 create mode 100644 app/assets/javascripts/admin/routes/admin-permalinks.js.es6
 create mode 100644 app/assets/javascripts/admin/templates/components/permalink-form.hbs
 create mode 100644 app/assets/javascripts/admin/templates/permalinks.hbs
 create mode 100644 app/assets/javascripts/admin/templates/permalinks_list_item.hbs
 create mode 100644 app/assets/javascripts/admin/views/permalinks-list.js.es6
 create mode 100644 app/controllers/admin/permalinks_controller.rb
 create mode 100644 app/serializers/permalink_serializer.rb
 create mode 100644 spec/controllers/admin/permalinks_controller_spec.rb

diff --git a/app/assets/javascripts/admin/components/permalink-form.js.es6 b/app/assets/javascripts/admin/components/permalink-form.js.es6
new file mode 100644
index 000000000..1bb29e52e
--- /dev/null
+++ b/app/assets/javascripts/admin/components/permalink-form.js.es6
@@ -0,0 +1,56 @@
+export default Ember.Component.extend({
+  classNames: ['permalink-form'],
+  formSubmitted: false,
+  permalinkType: 'topic_id',
+
+  permalinkTypes: function() {
+    return [
+      {id: 'topic_id',       name: I18n.t('admin.permalink.topic_id')},
+      {id: 'post_id',  name: I18n.t('admin.permalink.post_id')},
+      {id: 'category_id', name: I18n.t('admin.permalink.category_id')},
+      {id: 'external_url', name: I18n.t('admin.permalink.external_url')}
+    ];
+  }.property(),
+
+  permalinkTypePlaceholder: function() {
+    return 'admin.permalink.' + this.get('permalinkType');
+  }.property('permalinkType'),
+
+  actions: {
+    submit: function() {
+      if (!this.get('formSubmitted')) {
+        const self = this;
+        self.set('formSubmitted', true);
+        const permalink = Discourse.Permalink.create({url: self.get('url'), permalink_type: self.get('permalinkType'), permalink_type_value: self.get('permalink_type_value')});
+        permalink.save().then(function(result) {
+          self.set('url', '');
+          self.set('permalink_type_value', '');
+          self.set('formSubmitted', false);
+          self.sendAction('action', Discourse.Permalink.create(result.permalink));
+          Em.run.schedule('afterRender', function() { self.$('.permalink-url').focus(); });
+        }, function(e) {
+          self.set('formSubmitted', false);
+          let error;
+          if (e.responseJSON && e.responseJSON.errors) {
+            error = I18n.t("generic_error_with_reason", {error: e.responseJSON.errors.join('. ')});
+          } else {
+            error = I18n.t("generic_error");
+          }
+          bootbox.alert(error, function() { self.$('.permalink-url').focus(); });
+        });
+      }
+    }
+  },
+
+  didInsertElement: function() {
+    var self = this;
+    self._super();
+    Em.run.schedule('afterRender', function() {
+      self.$('.external-url').keydown(function(e) {
+        if (e.keyCode === 13) { // enter key
+          self.send('submit');
+        }
+      });
+    });
+  }
+});
diff --git a/app/assets/javascripts/admin/controllers/admin-permalinks.js.es6 b/app/assets/javascripts/admin/controllers/admin-permalinks.js.es6
new file mode 100644
index 000000000..e03e5ebda
--- /dev/null
+++ b/app/assets/javascripts/admin/controllers/admin-permalinks.js.es6
@@ -0,0 +1,36 @@
+export default Ember.ArrayController.extend({
+  loading: false,
+  filter: null,
+
+  show: Discourse.debounce(function() {
+    var self = this;
+    self.set('loading', true);
+    Discourse.Permalink.findAll(self.get("filter")).then(function(result) {
+      self.set('model', result);
+      self.set('loading', false);
+    });
+  }, 250).observes("filter"),
+
+  actions: {
+    recordAdded(arg) {
+      this.get("model").unshiftObject(arg);
+    },
+
+    destroy: function(record) {
+      const self = this;
+      return bootbox.confirm(I18n.t("admin.permalink.delete_confirm"), I18n.t("no_value"), I18n.t("yes_value"), function(result) {
+        if (result) {
+          record.destroy().then(function(deleted) {
+            if (deleted) {
+              self.removeObject(record);
+            } else {
+              bootbox.alert(I18n.t("generic_error"));
+            }
+          }, function(){
+            bootbox.alert(I18n.t("generic_error"));
+          });
+        }
+      });
+    }
+  }
+});
diff --git a/app/assets/javascripts/admin/models/permalink.js.es6 b/app/assets/javascripts/admin/models/permalink.js.es6
new file mode 100644
index 000000000..761cd4ab5
--- /dev/null
+++ b/app/assets/javascripts/admin/models/permalink.js.es6
@@ -0,0 +1,22 @@
+const Permalink = Discourse.Model.extend({
+  save: function() {
+    return Discourse.ajax("/admin/permalinks.json", {
+      type: 'POST',
+      data: {url: this.get('url'), permalink_type: this.get('permalink_type'), permalink_type_value: this.get('permalink_type_value')}
+    });
+  },
+
+  destroy: function() {
+    return Discourse.ajax("/admin/permalinks/" + this.get('id') + ".json", {type: 'DELETE'});
+  }
+});
+
+Permalink.reopenClass({
+  findAll: function(filter) {
+    return Discourse.ajax("/admin/permalinks.json", { data: { filter: filter } }).then(function(permalinks) {
+      return permalinks.map(p => Discourse.Permalink.create(p));
+    });
+  }
+});
+
+export default Permalink;
diff --git a/app/assets/javascripts/admin/routes/admin-permalinks.js.es6 b/app/assets/javascripts/admin/routes/admin-permalinks.js.es6
new file mode 100644
index 000000000..72b7f444d
--- /dev/null
+++ b/app/assets/javascripts/admin/routes/admin-permalinks.js.es6
@@ -0,0 +1,9 @@
+export default Discourse.Route.extend({
+  model() {
+    return Discourse.Permalink.findAll();
+  },
+
+  setupController(controller, model) {
+    controller.set('model', model);
+  }
+});
diff --git a/app/assets/javascripts/admin/routes/admin-route-map.js.es6 b/app/assets/javascripts/admin/routes/admin-route-map.js.es6
index ab5f2e16c..daf3b1ced 100644
--- a/app/assets/javascripts/admin/routes/admin-route-map.js.es6
+++ b/app/assets/javascripts/admin/routes/admin-route-map.js.es6
@@ -22,6 +22,7 @@ export default {
       });
       this.resource('adminUserFields', { path: '/user_fields' });
       this.resource('adminEmojis', { path: '/emojis' });
+      this.resource('adminPermalinks', { path: '/permalinks' });
     });
     this.route('api');
 
diff --git a/app/assets/javascripts/admin/templates/components/permalink-form.hbs b/app/assets/javascripts/admin/templates/components/permalink-form.hbs
new file mode 100644
index 000000000..f5155c27c
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/components/permalink-form.hbs
@@ -0,0 +1,5 @@
+<b>{{i18n 'admin.permalink.form.label'}}</b>
+{{text-field value=url disabled=formSubmitted class="permalink-url" placeholderKey="admin.permalink.url" autocorrect="off" autocapitalize="off"}}
+{{combo-box content=permalinkTypes value=permalinkType}}
+{{text-field value=permalink_type_value disabled=formSubmitted class="external-url" placeholderKey=permalinkTypePlaceholder autocorrect="off" autocapitalize="off"}}
+<button class="btn" {{action "submit" target="view"}} {{bind-attr disabled="formSubmitted"}}>{{i18n 'admin.permalink.form.add'}}</button>
diff --git a/app/assets/javascripts/admin/templates/customize.hbs b/app/assets/javascripts/admin/templates/customize.hbs
index 1622539bb..c009909e1 100644
--- a/app/assets/javascripts/admin/templates/customize.hbs
+++ b/app/assets/javascripts/admin/templates/customize.hbs
@@ -4,6 +4,7 @@
   {{nav-item route='adminSiteText' label='admin.site_text.title'}}
   {{nav-item route='adminUserFields' label='admin.user_fields.title'}}
   {{nav-item route='adminEmojis' label='admin.emoji.title'}}
+  {{nav-item route='adminPermalinks' label='admin.permalink.title'}}
 {{/admin-nav}}
 
 <div class="admin-container">
diff --git a/app/assets/javascripts/admin/templates/permalinks.hbs b/app/assets/javascripts/admin/templates/permalinks.hbs
new file mode 100644
index 000000000..47717627b
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/permalinks.hbs
@@ -0,0 +1,25 @@
+<div class="permalink-title"><h2>{{i18n 'admin.permalink.title'}}</h2></div>
+<div class="pull-right">
+  {{text-field value=filter class="url-input" placeholderKey="admin.permalink.form.filter" autocorrect="off" autocapitalize="off"}}
+</div>
+{{permalink-form action="recordAdded"}}
+<br/>
+
+{{#conditional-loading-spinner condition=loading}}
+  {{#if model.length}}
+    <div class='table admin-logs-table permalinks'>
+      <div class="heading-container">
+        <div class="col heading first url">{{i18n 'admin.permalink.url'}}</div>
+        <div class="col heading topic_id">{{i18n 'admin.permalink.topic_id'}}</div>
+        <div class="col heading post_id">{{i18n 'admin.permalink.post_id'}}</div>
+        <div class="col heading category_id">{{i18n 'admin.permalink.category_id'}}</div>
+        <div class="col heading external_url">{{i18n 'admin.permalink.external_url'}}</div>
+        <div class="col heading actions"></div>
+        <div class="clearfix"></div>
+      </div>
+      {{view 'permalinks-list' content=controller}}
+    </div>
+  {{else}}
+    {{i18n 'search.no_results'}}
+  {{/if}}
+{{/conditional-loading-spinner}}
diff --git a/app/assets/javascripts/admin/templates/permalinks_list_item.hbs b/app/assets/javascripts/admin/templates/permalinks_list_item.hbs
new file mode 100644
index 000000000..4fbf2db55
--- /dev/null
+++ b/app/assets/javascripts/admin/templates/permalinks_list_item.hbs
@@ -0,0 +1,7 @@
+<div class="col first url">{{url}}</div>
+<div class="col topic_id">{{topic_id}}</div>
+<div class="col post_id">{{post_id}}</div>
+<div class="col category_id">{{category_id}}</div>
+<div class="col external_url">{{external_url}}</div>
+<div class="col action"><button class="btn btn-danger" {{action "destroy" this}}><i class="fa fa-trash-o"></i></button></div>
+<div class="clearfix"></div>
diff --git a/app/assets/javascripts/admin/views/permalinks-list.js.es6 b/app/assets/javascripts/admin/views/permalinks-list.js.es6
new file mode 100644
index 000000000..c42543768
--- /dev/null
+++ b/app/assets/javascripts/admin/views/permalinks-list.js.es6
@@ -0,0 +1,8 @@
+import ListView from 'ember-addons/list-view';
+import ListItemView from 'ember-addons/list-item-view';
+
+export default ListView.extend({
+  height: 700,
+  rowHeight: 32,
+  itemViewClass: ListItemView.extend({templateName: "admin/templates/permalinks_list_item"})
+});
diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss
index 1aeabb401..05891ad34 100644
--- a/app/assets/stylesheets/common/admin/admin_base.scss
+++ b/app/assets/stylesheets/common/admin/admin_base.scss
@@ -1199,7 +1199,7 @@ table.api-keys {
   position: absolute;
 }
 
-.staff-actions, .screened-emails, .screened-urls, .screened-ip-addresses {
+.staff-actions, .screened-emails, .screened-urls, .screened-ip-addresses, .permalinks {
 
   border-bottom: dotted 1px scale-color($primary, $lightness: 75%);
 
@@ -1469,3 +1469,19 @@ table#user-badges {
     width: 90%;
   }
 }
+
+// Permalinks
+
+.permalinks {
+  .url, .external_url {
+    width: 300px;
+  }
+  .action, .topic_id, .post_id, .category_id {
+    text-align: center;
+    width: 9.9099%;
+  }
+}
+
+.permalink-title {
+  margin-bottom: 10px;
+}
diff --git a/app/controllers/admin/permalinks_controller.rb b/app/controllers/admin/permalinks_controller.rb
new file mode 100644
index 000000000..9c2eb47fb
--- /dev/null
+++ b/app/controllers/admin/permalinks_controller.rb
@@ -0,0 +1,39 @@
+class Admin::PermalinksController < Admin::AdminController
+
+  before_filter :fetch_permalink, only: [:destroy]
+
+  def index
+    filter = params[:filter]
+
+    permalinks = Permalink
+    permalinks = permalinks.where('url ILIKE :filter OR external_url ILIKE :filter', filter: "%#{params[:filter]}%") if filter.present?
+    permalinks = permalinks.limit(100).order('created_at desc').to_a
+
+    render_serialized(permalinks, PermalinkSerializer)
+  end
+
+  def create
+    params.require(:url)
+    params.require(:permalink_type)
+    params.require(:permalink_type_value)
+
+    permalink = Permalink.new(:url => params[:url], params[:permalink_type] => params[:permalink_type_value])
+    if permalink.save
+      render_serialized(permalink, PermalinkSerializer)
+    else
+      render_json_error(permalink)
+    end
+  end
+
+  def destroy
+    @permalink.destroy
+    render json: success_json
+  end
+
+  private
+
+  def fetch_permalink
+    @permalink = Permalink.find(params[:id])
+  end
+
+end
diff --git a/app/serializers/permalink_serializer.rb b/app/serializers/permalink_serializer.rb
new file mode 100644
index 000000000..2d621ed72
--- /dev/null
+++ b/app/serializers/permalink_serializer.rb
@@ -0,0 +1,3 @@
+class PermalinkSerializer < ApplicationSerializer
+  attributes :id, :url, :topic_id, :post_id, :category_id, :external_url
+end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 52e0ca025..4b6020abf 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -2443,6 +2443,19 @@ en:
         image: "Image"
         delete_confirm: "Are you sure you want to delete the :%{name}: emoji?"
 
+      permalink:
+        title: "Permalinks"
+        url: "URL"
+        topic_id: "Topic ID"
+        post_id: "Post ID"
+        category_id: "Category ID"
+        external_url: "External URL"
+        delete_confirm: Are you sure you want to delete this permalink?
+        form:
+          label: "New:"
+          add: "Add"
+          filter: "Search (URL or External URL)"
+
     lightbox:
       download: "download"
 
diff --git a/config/routes.rb b/config/routes.rb
index 0553fd322..f691a8c2e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -133,6 +133,7 @@ Discourse::Application.routes.draw do
     get "customize" => "color_schemes#index", constraints: AdminConstraint.new
     get "customize/css_html" => "site_customizations#index", constraints: AdminConstraint.new
     get "customize/colors" => "color_schemes#index", constraints: AdminConstraint.new
+    get "customize/permalinks" => "permalinks#index", constraints: AdminConstraint.new
     get "flags" => "flags#index"
     get "flags/:filter" => "flags#index"
     post "flags/agree/:id" => "flags#agree"
@@ -148,6 +149,8 @@ Discourse::Application.routes.draw do
 
     resources :color_schemes, constraints: AdminConstraint.new
 
+    resources :permalinks, constraints: AdminConstraint.new
+
     get "version_check" => "versions#show"
 
     resources :dashboard, only: [:index] do
diff --git a/spec/controllers/admin/permalinks_controller_spec.rb b/spec/controllers/admin/permalinks_controller_spec.rb
new file mode 100644
index 000000000..c885363bf
--- /dev/null
+++ b/spec/controllers/admin/permalinks_controller_spec.rb
@@ -0,0 +1,51 @@
+require 'spec_helper'
+
+describe Admin::PermalinksController do
+
+  it "is a subclass of AdminController" do
+    expect(Admin::PermalinksController < Admin::AdminController).to eq(true)
+  end
+
+  let!(:user) { log_in(:admin) }
+
+  describe 'index' do
+    it 'filters url' do
+      Fabricate(:permalink, url: "/forum/23")
+      Fabricate(:permalink, url: "/forum/98")
+      Fabricate(:permalink, url: "/discuss/topic/45")
+      Fabricate(:permalink, url: "/discuss/topic/76")
+
+      xhr :get, :index, filter: "topic"
+
+      expect(response).to be_success
+      result = JSON.parse(response.body)
+      expect(result.length).to eq(2)
+    end
+
+    it 'filters external url' do
+      Fabricate(:permalink, external_url: "http://google.com")
+      Fabricate(:permalink, external_url: "http://wikipedia.org")
+      Fabricate(:permalink, external_url: "http://www.discourse.org")
+      Fabricate(:permalink, external_url: "http://try.discourse.org")
+
+      xhr :get, :index, filter: "discourse"
+
+      expect(response).to be_success
+      result = JSON.parse(response.body)
+      expect(result.length).to eq(2)
+    end
+
+    it 'filters url and external url both' do
+      Fabricate(:permalink, url: "/forum/23", external_url: "http://google.com")
+      Fabricate(:permalink, url: "/discourse/98", external_url: "http://wikipedia.org")
+      Fabricate(:permalink, url: "/discuss/topic/45", external_url: "http://discourse.org")
+      Fabricate(:permalink, url: "/discuss/topic/76", external_url: "http://try.discourse.org")
+
+      xhr :get, :index, filter: "discourse"
+
+      expect(response).to be_success
+      result = JSON.parse(response.body)
+      expect(result.length).to eq(3)
+    end
+  end
+end