From 342eba437468b2f98f47dfa0a255a2af607d4b74 Mon Sep 17 00:00:00 2001 From: Kane York Date: Thu, 27 Aug 2015 10:14:59 -0700 Subject: [PATCH] FEATURE: Category reordering dialog --- .../discourse/components/number-field.js.es6 | 29 ++++++ .../controllers/reorder-categories.js.es6 | 95 +++++++++++++++++++ .../discourse/lib/binary-search.js.es6 | 29 ++++++ .../routes/discovery-categories.js.es6 | 4 + .../templates/modal/reorder-categories.hbs | 36 +++++++ .../templates/navigation/categories.hbs | 5 +- .../discourse/views/reorder-categories.js.es6 | 80 ++++++++++++++++ app/assets/javascripts/main_include.js | 4 +- app/assets/stylesheets/admin.scss | 3 +- .../stylesheets/common/admin/cat_reorder.scss | 28 ++++++ .../stylesheets/common/base/discourse.scss | 3 + app/controllers/categories_controller.rb | 20 +++- app/serializers/basic_category_serializer.rb | 1 + config/locales/client.en.yml | 5 + config/routes.rb | 1 + .../controllers/categories_controller_spec.rb | 36 +++++++ 16 files changed, 373 insertions(+), 6 deletions(-) create mode 100644 app/assets/javascripts/discourse/components/number-field.js.es6 create mode 100644 app/assets/javascripts/discourse/controllers/reorder-categories.js.es6 create mode 100644 app/assets/javascripts/discourse/lib/binary-search.js.es6 create mode 100644 app/assets/javascripts/discourse/templates/modal/reorder-categories.hbs create mode 100644 app/assets/javascripts/discourse/views/reorder-categories.js.es6 create mode 100644 app/assets/stylesheets/common/admin/cat_reorder.scss 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 @@ +
+ + + +
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