mirror of
https://github.com/codeninjasllc/discourse.git
synced 2024-11-28 01:56:01 -05:00
Merge pull request #3703 from riking/category-reorder
FEATURE: Category reordering dialog
This commit is contained in:
commit
35998e1b74
16 changed files with 373 additions and 6 deletions
|
@ -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
app/assets/javascripts/discourse/lib/binary-search.js.es6
Normal file
29
app/assets/javascripts/discourse/lib/binary-search.js.es6
Normal 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);
|
||||
}
|
|
@ -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"));
|
||||
},
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -1 +1,2 @@
|
|||
@import "common/admin/admin_base"
|
||||
@import "common/admin/admin_base";
|
||||
@import "common/admin/cat_reorder";
|
||||
|
|
28
app/assets/stylesheets/common/admin/cat_reorder.scss
Normal file
28
app/assets/stylesheets/common/admin/cat_reorder.scss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -7,6 +7,7 @@ class BasicCategorySerializer < ApplicationSerializer
|
|||
:slug,
|
||||
:topic_count,
|
||||
:post_count,
|
||||
:position,
|
||||
:description,
|
||||
:description_text,
|
||||
:topic_url,
|
||||
|
|
|
@ -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.'
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue