diff --git a/app/assets/javascripts/discourse/components/number-field.js.es6 b/app/assets/javascripts/discourse/components/number-field.js.es6
new file mode 100644
index 00000000000..5104138c08e
--- /dev/null
+++ b/app/assets/javascripts/discourse/components/number-field.js.es6
@@ -0,0 +1,29 @@
+import computed from 'ember-addons/ember-computed-decorators';
+
+export default Ember.TextField.extend({
+
+ classNameBindings: ['invalid'],
+
+ @computed('number')
+ value: {
+ get(number) {
+ return parseInt(number);
+ },
+ set(value) {
+ const num = parseInt(value);
+ if (isNaN(num)) {
+ this.set('invalid', true);
+ return value;
+ } else {
+ this.set('invalid', false);
+ this.set('number', num);
+ return num.toString();
+ }
+ }
+ },
+
+ @computed("placeholderKey")
+ placeholder(key) {
+ return key ? I18n.t(key) : "";
+ }
+});
diff --git a/app/assets/javascripts/discourse/controllers/reorder-categories.js.es6 b/app/assets/javascripts/discourse/controllers/reorder-categories.js.es6
new file mode 100644
index 00000000000..0c7ab22caed
--- /dev/null
+++ b/app/assets/javascripts/discourse/controllers/reorder-categories.js.es6
@@ -0,0 +1,95 @@
+import ModalFunctionality from 'discourse/mixins/modal-functionality';
+const BufferedProxy = window.BufferedProxy; // import BufferedProxy from 'ember-buffered-proxy/proxy';
+import binarySearch from 'discourse/lib/binary-search';
+import { popupAjaxError } from 'discourse/lib/ajax-error';
+import computed from "ember-addons/ember-computed-decorators";
+import Ember from 'ember';
+
+export default Ember.Controller.extend(ModalFunctionality, Ember.Evented, {
+
+ @computed("site.categories")
+ categoriesBuffered(categories) {
+ const bufProxy = Ember.ObjectProxy.extend(BufferedProxy);
+ return categories.map(c => bufProxy.create({ content: c }));
+ },
+
+ // uses propertyDidChange()
+ @computed('categoriesBuffered')
+ categoriesGrouped(cats) {
+ const map = {};
+ cats.forEach((cat) => {
+ const p = cat.get('position') || 0;
+ if (!map[p]) {
+ map[p] = {pos: p, cats: [cat]};
+ } else {
+ map[p].cats.push(cat);
+ }
+ });
+ const result = [];
+ Object.keys(map).map(p => parseInt(p)).sort((a,b) => a-b).forEach(function(pos) {
+ result.push(map[pos]);
+ });
+ return result;
+ },
+
+ showApplyAll: function() {
+ let anyChanged = false;
+ this.get('categoriesBuffered').forEach(bc => { anyChanged = anyChanged || bc.get('hasBufferedChanges') });
+ return anyChanged;
+ }.property('categoriesBuffered.@each.hasBufferedChanges'),
+
+ saveDisabled: Ember.computed.alias('showApplyAll'),
+
+ moveDir(cat, dir) {
+ const grouped = this.get('categoriesGrouped'),
+ curPos = cat.get('position'),
+ curGroupIdx = binarySearch(grouped, curPos, "pos"),
+ curGroup = grouped[curGroupIdx];
+
+ if (curGroup.cats.length === 1 && ((dir === -1 && curGroupIdx !== 0) || (dir === 1 && curGroupIdx !== (grouped.length - 1)))) {
+ const nextGroup = grouped[curGroupIdx + dir],
+ nextPos = nextGroup.pos;
+ cat.set('position', nextPos);
+ } else {
+ cat.set('position', curPos + dir);
+ }
+ cat.applyBufferedChanges();
+ Ember.run.next(this, () => {
+ this.propertyDidChange('categoriesGrouped');
+ Ember.run.schedule('afterRender', this, () => {
+ this.set('scrollIntoViewId', cat.get('id'));
+ this.trigger('scrollIntoView');
+ });
+ });
+ },
+
+ actions: {
+
+ moveUp(cat) {
+ this.moveDir(cat, -1);
+ },
+ moveDown(cat) {
+ this.moveDir(cat, 1);
+ },
+
+ commit() {
+ this.get('categoriesBuffered').forEach(bc => {
+ if (bc.get('hasBufferedChanges')) {
+ bc.applyBufferedChanges();
+ }
+ });
+ this.propertyDidChange('categoriesGrouped');
+ },
+
+ saveOrder() {
+ const data = {};
+ this.get('categoriesBuffered').forEach((cat) => {
+ data[cat.get('id')] = cat.get('position');
+ });
+ Discourse.ajax('/categories/reorder',
+ {type: 'POST', data: {mapping: JSON.stringify(data)}}).
+ then(() => this.send("closeModal")).
+ catch(popupAjaxError);
+ }
+ }
+});
diff --git a/app/assets/javascripts/discourse/lib/binary-search.js.es6 b/app/assets/javascripts/discourse/lib/binary-search.js.es6
new file mode 100644
index 00000000000..03675866e0a
--- /dev/null
+++ b/app/assets/javascripts/discourse/lib/binary-search.js.es6
@@ -0,0 +1,29 @@
+// The binarySearch() function is licensed under the UNLICENSE
+// https://github.com/Olical/binary-search
+
+// Modified for use in Discourse
+
+export default function binarySearch(list, target, keyProp) {
+ var min = 0;
+ var max = list.length - 1;
+ var guess;
+ var keyProperty = keyProp || "id";
+
+ while (min <= max) {
+ guess = Math.floor((min + max) / 2);
+
+ if (Em.get(list[guess], keyProperty) === target) {
+ return guess;
+ }
+ else {
+ if (Em.get(list[guess], keyProperty) < target) {
+ min = guess + 1;
+ }
+ else {
+ max = guess - 1;
+ }
+ }
+ }
+
+ return -Math.floor((min + max) / 2);
+}
diff --git a/app/assets/javascripts/discourse/routes/discovery-categories.js.es6 b/app/assets/javascripts/discourse/routes/discovery-categories.js.es6
index d8a34f06253..297fc1ac1ee 100644
--- a/app/assets/javascripts/discourse/routes/discovery-categories.js.es6
+++ b/app/assets/javascripts/discourse/routes/discovery-categories.js.es6
@@ -56,6 +56,10 @@ const DiscoveryCategoriesRoute = Discourse.Route.extend(OpenComposer, {
this.controllerFor("editCategory").set("selectedTab", "general");
},
+ reorderCategories() {
+ showModal("reorderCategories");
+ },
+
createTopic() {
this.openComposer(this.controllerFor("discovery/categories"));
},
diff --git a/app/assets/javascripts/discourse/templates/modal/reorder-categories.hbs b/app/assets/javascripts/discourse/templates/modal/reorder-categories.hbs
new file mode 100644
index 00000000000..7a5b3172b66
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/modal/reorder-categories.hbs
@@ -0,0 +1,36 @@
+
+
+
+
+
+ Position |
+ Category |
+
+ {{#each categoriesGrouped as |group|}}
+
+ {{#each group.cats as |cat|}}
+
+
+ {{number-field number=cat.position}}
+ {{d-button class="no-text" action="moveUp" actionParam=cat icon="arrow-up"}}
+ {{d-button class="no-text" action="moveDown" actionParam=cat icon="arrow-down"}}
+ {{#if cat.hasBufferedChanges}}
+ {{d-button class="no-text" action="commit" icon="check"}}
+ {{/if}}
+ |
+ {{category-badge cat allowUncategorized="true"}} |
+
+ {{/each}}
+
+ {{/each}}
+
+
+
+
+
+
diff --git a/app/assets/javascripts/discourse/templates/navigation/categories.hbs b/app/assets/javascripts/discourse/templates/navigation/categories.hbs
index 6ba5509e9bb..c6b4c1cbbc9 100644
--- a/app/assets/javascripts/discourse/templates/navigation/categories.hbs
+++ b/app/assets/javascripts/discourse/templates/navigation/categories.hbs
@@ -3,7 +3,10 @@
{{navigation-bar navItems=navItems filterMode=filterMode}}
{{#if canCreateCategory}}
-
+ {{d-button action="createCategory" icon="plus" label="category.create"}}
+ {{#if siteSettings.fixed_category_positions}}
+ {{d-button action="reorderCategories" icon="random" label="category.reorder"}}
+ {{/if}}
{{/if}}
{{#if canCreateTopic}}
diff --git a/app/assets/javascripts/discourse/views/reorder-categories.js.es6 b/app/assets/javascripts/discourse/views/reorder-categories.js.es6
new file mode 100644
index 00000000000..570c9ec9637
--- /dev/null
+++ b/app/assets/javascripts/discourse/views/reorder-categories.js.es6
@@ -0,0 +1,80 @@
+import ModalBodyView from "discourse/views/modal-body";
+
+export default ModalBodyView.extend({
+ title: I18n.t('categories.reorder.title'),
+ templateName: 'modal/reorder-categories',
+
+ _setup: function() {
+ this.get('controller').on('scrollIntoView', this, this.scrollIntoView);
+ }.on('didInsertElement'),
+ _teardown: function() {
+ this.get('controller').off('scrollIntoView', this, this.scrollIntoView);
+ this.set('prevScrollElem', null);
+ }.on('willClearRender'),
+
+ scrollIntoView() {
+ const elem = this.$('tr[data-category-id="' + this.get('controller.scrollIntoViewId') + '"]');
+ const scrollParent = this.$('.modal-body');
+ const eoff = elem.position();
+ const poff = $(document.getElementById('rc-scroll-anchor')).position();
+ const currHeight = scrollParent.height();
+
+ elem[0].className = "highlighted";
+
+ const goal = eoff.top - poff.top - currHeight / 2,
+ current = scrollParent.scrollTop();
+ scrollParent.scrollTop(9999999);
+ const max = scrollParent.scrollTop();
+ scrollParent.scrollTop(current);
+
+ const doneTimeout = setTimeout(function() {
+ elem[0].className = "highlighted done";
+ setTimeout(function() {
+ elem[0].className = "";
+ }, 2000);
+ }, 0);
+
+ if (goal > current - currHeight / 4 && goal < current + currHeight / 4) {
+ // Too close to goal
+ return;
+ }
+ if (max - current < 10 && goal > current) {
+ // Too close to bottom
+ return;
+ }
+ if (current < 10 && goal < current) {
+ // Too close to top
+ return;
+ }
+
+ if (!window.requestAnimationFrame) {
+ scrollParent.scrollTop(goal);
+ } else {
+ clearTimeout(doneTimeout);
+ const startTime = performance.now();
+ const duration = 100;
+
+ function doScroll(timestamp) {
+ let progress = (timestamp - startTime) / duration;
+ if (progress > 1) {
+ progress = 1;
+ setTimeout(function() {
+ elem[0].className = "highlighted done";
+ setTimeout(function() {
+ elem[0].className = "";
+ }, 2000);
+ }, 0);
+ } else if (progress < 0) {
+ progress = 0;
+ }
+ if (progress < 1) {
+ window.requestAnimationFrame(doScroll);
+ }
+
+ const iprogress = 1 - progress;
+ scrollParent.scrollTop(goal * progress + current * iprogress);
+ }
+ window.requestAnimationFrame(doScroll);
+ }
+ }
+});
diff --git a/app/assets/javascripts/main_include.js b/app/assets/javascripts/main_include.js
index 0ff24305876..5ea3455611d 100644
--- a/app/assets/javascripts/main_include.js
+++ b/app/assets/javascripts/main_include.js
@@ -94,13 +94,11 @@
//= require ./discourse/lib/export-result
//= require ./discourse/dialects/dialect
//= require ./discourse/lib/emoji/emoji
-//= require ./discourse/lib/sharing
-//= require discourse/lib/desktop-notifications
+//= require_tree ./discourse/lib
//= require ./discourse/router
//= require_tree ./discourse/dialects
//= require_tree ./discourse/controllers
-//= require_tree ./discourse/lib
//= require_tree ./discourse/models
//= require_tree ./discourse/components
//= require_tree ./discourse/views
diff --git a/app/assets/stylesheets/admin.scss b/app/assets/stylesheets/admin.scss
index d1de50da4e4..577f9aba1c5 100644
--- a/app/assets/stylesheets/admin.scss
+++ b/app/assets/stylesheets/admin.scss
@@ -1 +1,2 @@
-@import "common/admin/admin_base"
+@import "common/admin/admin_base";
+@import "common/admin/cat_reorder";
diff --git a/app/assets/stylesheets/common/admin/cat_reorder.scss b/app/assets/stylesheets/common/admin/cat_reorder.scss
new file mode 100644
index 00000000000..d5619e993c3
--- /dev/null
+++ b/app/assets/stylesheets/common/admin/cat_reorder.scss
@@ -0,0 +1,28 @@
+.reorder-categories {
+ input {
+ width: 4em;
+ }
+ .th-pos {
+ width: calc(4em + 150px);
+ }
+ tbody tr {
+ background-color: transparent;
+ transition: background 0s ease;
+ &.highlighted {
+ background-color: rgba($highlight, 0.4);
+ &.done {
+ background-color: transparent;
+ transition-duration: 1s;
+ }
+ }
+ &:first-child td {
+ padding-top: 7px;
+ }
+ }
+ tbody {
+ border-bottom: 1px solid blend-primary-secondary(50%);
+ }
+ table {
+ padding-bottom: 150px;
+ }
+}
diff --git a/app/assets/stylesheets/common/base/discourse.scss b/app/assets/stylesheets/common/base/discourse.scss
index c8cedb4a6cd..d8b47398043 100644
--- a/app/assets/stylesheets/common/base/discourse.scss
+++ b/app/assets/stylesheets/common/base/discourse.scss
@@ -144,6 +144,9 @@ body {
}
}
+input[type].invalid {
+ background-color: dark-light-choose(scale-color($danger, $lightness: 80%), scale-color($danger, $lightness: -60%));
+}
.wmd-input {
resize: none;
diff --git a/app/controllers/categories_controller.rb b/app/controllers/categories_controller.rb
index 9d3ac191e9c..b63ec503dbf 100644
--- a/app/controllers/categories_controller.rb
+++ b/app/controllers/categories_controller.rb
@@ -37,7 +37,7 @@ class CategoriesController < ApplicationController
end
def move
- guardian.ensure_can_create!(Category)
+ guardian.ensure_can_create_category!
params.require("category_id")
params.require("position")
@@ -50,6 +50,24 @@ class CategoriesController < ApplicationController
end
end
+ def reorder
+ guardian.ensure_can_create_category!
+
+ params.require(:mapping)
+ change_requests = MultiJson.load(params[:mapping])
+ by_category = Hash[change_requests.map { |cat, pos| [Category.find(cat.to_i), pos] }]
+
+ unless guardian.is_admin?
+ raise Discourse::InvalidAccess unless by_category.keys.all? { |c| guardian.can_see_category? c }
+ end
+
+ by_category.each do |cat, pos|
+ cat.position = pos
+ cat.save if cat.position_changed?
+ end
+ render json: success_json
+ end
+
def show
if Category.topic_create_allowed(guardian).where(id: @category.id).exists?
@category.permission = CategoryGroup.permission_types[:full]
diff --git a/app/serializers/basic_category_serializer.rb b/app/serializers/basic_category_serializer.rb
index 96b0268cfae..f62968a59f2 100644
--- a/app/serializers/basic_category_serializer.rb
+++ b/app/serializers/basic_category_serializer.rb
@@ -7,6 +7,7 @@ class BasicCategorySerializer < ApplicationSerializer
:slug,
:topic_count,
:post_count,
+ :position,
:description,
:description_text,
:topic_url,
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index e92de892cfe..06f06b09f58 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -367,6 +367,10 @@ en:
all_subcategories: "all"
no_subcategory: "none"
category: "Category"
+ reorder:
+ title: "Reorder Categories"
+ save: "Save Order"
+ apply_all: "Apply"
posts: "Posts"
topics: "Topics"
latest: "Latest"
@@ -1546,6 +1550,7 @@ en:
add_permission: "Add Permission"
this_year: "this year"
position: "position"
+ reorder: "Reorder"
default_position: "Default Position"
position_disabled: "Categories will be displayed in order of activity. To control the order of categories in lists, "
position_disabled_click: 'enable the "fixed category positions" setting.'
diff --git a/config/routes.rb b/config/routes.rb
index f10ea5c2e95..d2adc0cfdb4 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -382,6 +382,7 @@ Discourse::Application.routes.draw do
resources :categories, :except => :show
post "category/:category_id/move" => "categories#move"
+ post "categories/reorder" => "categories#reorder"
post "category/:category_id/notifications" => "categories#set_notifications"
put "category/:category_id/slug" => "categories#update_slug"
diff --git a/spec/controllers/categories_controller_spec.rb b/spec/controllers/categories_controller_spec.rb
index e838b2e9714..f03cfd05e8c 100644
--- a/spec/controllers/categories_controller_spec.rb
+++ b/spec/controllers/categories_controller_spec.rb
@@ -95,6 +95,42 @@ describe CategoriesController do
end
+ describe "reorder" do
+ it "reorders the categories" do
+ admin = log_in(:admin)
+
+ c1 = Fabricate(:category)
+ c2 = Fabricate(:category)
+ c3 = Fabricate(:category)
+ c4 = Fabricate(:category)
+ if c3.id < c2.id
+ tmp = c3; c2 = c3; c3 = tmp;
+ end
+ c1.position = 8
+ c2.position = 6
+ c3.position = 7
+ c4.position = 5
+
+ payload = {}
+ payload[c1.id] = 4
+ payload[c2.id] = 6
+ payload[c3.id] = 6
+ payload[c4.id] = 5
+
+ xhr :post, :reorder, mapping: MultiJson.dump(payload)
+
+ SiteSetting.fixed_category_positions = true
+ list = CategoryList.new(Guardian.new(admin))
+ expect(list.categories).to eq([
+ Category.find(SiteSetting.uncategorized_category_id),
+ c1,
+ c4,
+ c2,
+ c3
+ ])
+ end
+ end
+
describe "update" do
it "requires the user to be logged in" do