Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FEATURE: Category reordering dialog #3703

Merged
merged 1 commit into from Sep 7, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
29 changes: 29 additions & 0 deletions 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) : "";
}
});
@@ -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);
}
}
});
29 changes: 29 additions & 0 deletions 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);
}
Expand Up @@ -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"));
},
Expand Down
@@ -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>
Expand Up @@ -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>
Expand Down
80 changes: 80 additions & 0 deletions 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);
}
}
});
4 changes: 1 addition & 3 deletions app/assets/javascripts/main_include.js
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion app/assets/stylesheets/admin.scss
@@ -1 +1,2 @@
@import "common/admin/admin_base"
@import "common/admin/admin_base";
@import "common/admin/cat_reorder";
28 changes: 28 additions & 0 deletions 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;
}
}
3 changes: 3 additions & 0 deletions app/assets/stylesheets/common/base/discourse.scss
Expand Up @@ -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;
Expand Down
20 changes: 19 additions & 1 deletion app/controllers/categories_controller.rb
Expand Up @@ -37,7 +37,7 @@ def index
end

def move
guardian.ensure_can_create!(Category)
guardian.ensure_can_create_category!

params.require("category_id")
params.require("position")
Expand All @@ -50,6 +50,24 @@ def move
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]
Expand Down
1 change: 1 addition & 0 deletions app/serializers/basic_category_serializer.rb
Expand Up @@ -7,6 +7,7 @@ class BasicCategorySerializer < ApplicationSerializer
:slug,
:topic_count,
:post_count,
:position,
:description,
:description_text,
:topic_url,
Expand Down