Merge pull request #3703 from riking/category-reorder

FEATURE: Category reordering dialog
This commit is contained in:
Sam 2015-09-07 10:13:12 +10:00
commit 35998e1b74
16 changed files with 373 additions and 6 deletions

View file

@ -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) : "";
}
});

View file

@ -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);
}
}
});

View file

@ -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);
}

View file

@ -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"));
},

View file

@ -0,0 +1,36 @@
<div>
<div class="modal-body reorder-categories">
<div id="rc-scroll-anchor"></div>
<table>
<thead>
<th class="th-pos">Position</th>
<th class="th-cat">Category</th>
</thead>
{{#each categoriesGrouped as |group|}}
<tbody>
{{#each group.cats as |cat|}}
<tr data-category-id="{{cat.id}}">
<td>
{{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}}
</td>
<td>{{category-badge cat allowUncategorized="true"}}</td>
</tr>
{{/each}}
</tbody>
{{/each}}
</table>
<div id="rc-scroll-bottom"></div>
</div>
<div class="modal-footer">
{{#if showApplyAll}}
{{d-button action="commit" icon="check" label="categories.reorder.apply_all"}}
{{/if}}
{{d-button class="btn-primary" disabled=saveDisabled action="saveOrder" label="categories.reorder.save"}}
</div>
</div>

View file

@ -3,7 +3,10 @@
{{navigation-bar navItems=navItems filterMode=filterMode}}
{{#if canCreateCategory}}
<button class='btn btn-default' {{action "createCategory"}}><i class='fa fa-plus'></i>{{i18n 'category.create'}}</button>
{{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}}
<button id="create-topic" class='btn btn-default' {{action "createTopic"}}><i class='fa fa-plus'></i>{{i18n 'topic.create'}}</button>

View file

@ -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);
}
}
});

View file

@ -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

View file

@ -1 +1,2 @@
@import "common/admin/admin_base"
@import "common/admin/admin_base";
@import "common/admin/cat_reorder";

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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]

View file

@ -7,6 +7,7 @@ class BasicCategorySerializer < ApplicationSerializer
:slug,
:topic_count,
:post_count,
:position,
:description,
:description_text,
:topic_url,

View file

@ -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.'

View file

@ -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"

View file

@ -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