mirror of https://github.com/RubaXa/Sortable.git
RubaXa
10 years ago
22 changed files with 681 additions and 100 deletions
@ -1,26 +1,105 @@
|
||||
Packaging [Sortable](http://rubaxa.github.io/Sortable/) for [Meteor.js](http://meteor.com). |
||||
Reactive reorderable lists with [Sortable](http://rubaxa.github.io/Sortable/), |
||||
backed by [Meteor.js](http://meteor.com) collections: |
||||
|
||||
* new elements arriving in the collection will update the list as you expect |
||||
* elements removed from the collection will be removed from the list |
||||
* drag and drop between lists updates collections accordingly |
||||
|
||||
Demo: http://rubaxa-sortable.meteor.com |
||||
|
||||
# Meteor |
||||
|
||||
If you're new to Meteor, here's what the excitement is all about - |
||||
[watch the first two minutes](https://www.youtube.com/watch?v=fsi0aJ9yr2o); you'll be hooked by 1:28. |
||||
|
||||
That screencast is from 2012. In the meantime, Meteor has become a mature JavaScript-everywhere web |
||||
development framework. Read more at [Why Meteor](http://www.meteorpedia.com/read/Why_Meteor). |
||||
|
||||
|
||||
# Issues |
||||
# Usage |
||||
|
||||
If you encounter an issue while using this package, please CC @dandv when you file it in this repo. |
||||
Simplest invocation - order will be lost when the page is refreshed: |
||||
|
||||
```handlebars |
||||
{{sortable <collection|cursor|array>}} |
||||
``` |
||||
|
||||
Persist the sort order in the 'order' field of each document in the collection: |
||||
|
||||
```handlebars |
||||
{{sortable items=<collection|cursor|array> sortField="order"}} |
||||
``` |
||||
|
||||
Along with `items`, `sortField` is the only Meteor-specific option. If it's missing, the package will |
||||
assume there is a field called "order" in the collection, holding unique `Number`s such that every |
||||
`order` differs from that before and after it by at least 1. Basically, keep to 0, 1, 2, ... . |
||||
Try not to depend on a particular format for this field; it *is* though guaranteed that a `sort` will |
||||
produce lexicographical order, and that the order will be maintained after an arbitrary number of |
||||
reorderings, unlike with [naive solutions](http://programmers.stackexchange.com/questions/266451/maintain-ordered-collection-by-updating-as-few-order-fields-as-possible). |
||||
|
||||
|
||||
## Passing options to the Sortable library |
||||
|
||||
{{sortable items=<collection|cursor|array> option1=value1 option2=value2...}} |
||||
{{sortable items=<collection|cursor|array> options=myOptions}} |
||||
|
||||
For available options, please refer to [the main README](../README.md#options). You can pass them directly |
||||
or under the `options` object. Direct options (`key=value`) override those in `options`. It is best |
||||
to pass presentation-related options directly, and functionality-related settings in an `options` |
||||
object, as this will enable designers to work without needing to inspect the JavaScript code: |
||||
|
||||
# DONE |
||||
<template name="myTemplate"> |
||||
... |
||||
{{sortable items=Players handle=".sortable-handle" ghostClass="sortable-ghost" options=playerOptions}} |
||||
</template> |
||||
|
||||
* Instantiation test |
||||
Define the options in a helper for the template that calls Sortable: |
||||
|
||||
```js |
||||
Template.myTemplate.helpers({ |
||||
playerOptions: function () { |
||||
return { |
||||
group: { |
||||
name: "league", |
||||
pull: true, |
||||
put: false |
||||
}, |
||||
sort: false |
||||
}; |
||||
} |
||||
}); |
||||
``` |
||||
|
||||
|
||||
## Events |
||||
|
||||
All the original Sortable events are supported. In addition, they will receive |
||||
the data context in `event.data`. You can access `event.data.order` this way: |
||||
|
||||
```handlebars |
||||
{{sortable items=players options=playersOptions}} |
||||
|
||||
```js |
||||
Template.myTemplate.helpers({ |
||||
playersOptions: function () { |
||||
return { |
||||
onSort: function(/**Event*/event) { |
||||
console.log('Moved player #%d from %d to %d', |
||||
event.data.order, event.oldIndex, event.newIndex |
||||
); |
||||
} |
||||
}; |
||||
} |
||||
}); |
||||
``` |
||||
|
||||
|
||||
# Issues |
||||
|
||||
If you encounter an issue while using this package, please CC @dandv when you file it in this repo. |
||||
|
||||
|
||||
# TODO |
||||
|
||||
* Meteor collection backing |
||||
* Tests ensuring correct rendering with Meteor dynamic templates |
||||
* Array support |
||||
* Tests |
||||
* Misc. - see reactivize.js |
||||
|
@ -0,0 +1,11 @@
|
||||
# Meteor packages used by this project, one per line. |
||||
# |
||||
# 'meteor add' and 'meteor remove' will edit this file for you, |
||||
# but you can also edit it by hand. |
||||
|
||||
meteor-platform |
||||
autopublish |
||||
insecure |
||||
rubaxa:sortable |
||||
fezvrasta:bootstrap-material-design |
||||
# twbs:bootstrap |
@ -0,0 +1,57 @@
|
||||
.glyphicon { |
||||
vertical-align: baseline; |
||||
font-size: 80%; |
||||
margin-right: 0.5em; |
||||
} |
||||
|
||||
[class^="mdi-"], [class*=" mdi-"] { |
||||
vertical-align: baseline; |
||||
font-size: 90%; |
||||
margin-right: 0.4em; |
||||
} |
||||
|
||||
.list-pair { |
||||
display: flex; /* use the flexbox model */ |
||||
flex-direction: row; |
||||
} |
||||
.sortable { |
||||
/* font-size: 2em;*/ |
||||
} |
||||
|
||||
.sortable.source { |
||||
/*background: #9FA8DA;*/ |
||||
flex: 0 0 auto; |
||||
margin-right: 1em; |
||||
cursor: move; |
||||
cursor: -webkit-grabbing; |
||||
} |
||||
|
||||
.sortable.target { |
||||
/*background: #3F51B5;*/ |
||||
flex: 1 1 auto; |
||||
margin-left: 1em; |
||||
} |
||||
|
||||
.target .well { |
||||
|
||||
} |
||||
|
||||
.sortable-handle { |
||||
cursor: move; |
||||
cursor: -webkit-grabbing; |
||||
} |
||||
.sortable-handle.pull-right { |
||||
margin-top: 0.3em; |
||||
} |
||||
|
||||
.sortable-ghost { |
||||
opacity: 0.6; |
||||
} |
||||
|
||||
/* show the remove button on hover */ |
||||
.removable .close { |
||||
display: none; |
||||
} |
||||
.removable:hover .close { |
||||
display: block; |
||||
} |
@ -0,0 +1,50 @@
|
||||
<head> |
||||
<title>RubaXa:Sortable Demo</title> |
||||
</head> |
||||
|
||||
<body> |
||||
<div class="container"> |
||||
<div class="page-header"> |
||||
<h1>Custom attributes</h1> |
||||
</div> |
||||
|
||||
{{> typeDefinition}} |
||||
</div> |
||||
</body> |
||||
|
||||
<template name="typeDefinition"> |
||||
|
||||
<div class="row"> |
||||
<div class="list-pair col-sm-12"> |
||||
|
||||
<div class="sortable source list-group" id="types"> |
||||
{{#sortable items=types options=typesOptions}} |
||||
<div class="list-group-item well well-sm"> |
||||
{{{icon}}} {{name}} |
||||
</div> |
||||
{{/sortable}} |
||||
</div> |
||||
|
||||
<div class="sortable target" id="object"> |
||||
{{#sortable items=attributes animation="0" handle=".sortable-handle" ghostClass="sortable-ghost" options=attributesOptions}} |
||||
{{> sortableItemTarget}} |
||||
{{/sortable}} |
||||
</div> |
||||
|
||||
</div> |
||||
</div> |
||||
|
||||
|
||||
</template> |
||||
|
||||
<template name="sortableItemTarget"> |
||||
<div data-id="{{order}}" class="sortable-item removable well well-sm"> |
||||
{{{icon}}} |
||||
<i class="sortable-handle mdi-action-view-headline pull-right"></i> |
||||
<span class="name">{{name}}</span> |
||||
<span class="badge">{{order}}</span> |
||||
<button type="button" class="close" data-dismiss="alert"> |
||||
<span aria-hidden="true">×</span><span class="sr-only">Close</span> |
||||
</button> |
||||
</div> |
||||
</template> |
@ -0,0 +1,78 @@
|
||||
// Define an object type by dragging together attributes
|
||||
|
||||
Template.typeDefinition.helpers({ |
||||
types: function () { |
||||
return Types.find({}, { sort: { order: 1 } }); |
||||
}, |
||||
typesOptions: { |
||||
sortField: 'order', // defaults to 'order' anyway
|
||||
group: { |
||||
name: 'typeDefinition', |
||||
pull: 'clone', |
||||
put: false |
||||
}, |
||||
sort: false // don't allow reordering the types, just the attributes below
|
||||
}, |
||||
|
||||
attributes: function () { |
||||
return Attributes.find({}, { |
||||
sort: { order: 1 }, |
||||
transform: function (doc) { |
||||
doc.icon = Types.findOne({name: doc.type}).icon; |
||||
return doc; |
||||
} |
||||
}); |
||||
}, |
||||
attributesOptions: { |
||||
group: { |
||||
name: 'typeDefinition', |
||||
put: true |
||||
}, |
||||
onAdd: function (event) { |
||||
delete event.data._id; // Generate a new id when inserting in the Attributes collection. Otherwise, if we add the same type twice, we'll get an error that the ids are not unique.
|
||||
delete event.data.icon; |
||||
event.data.type = event.data.name; |
||||
event.data.name = 'Rename me' |
||||
}, |
||||
// event handler for reordering attributes
|
||||
onSort: function (event) { |
||||
console.log('Moved object %d from %d to %d', |
||||
event.data.order, event.oldIndex, event.newIndex |
||||
); |
||||
} |
||||
} |
||||
}); |
||||
|
||||
Template.sortableItemTarget.events({ |
||||
'dblclick .name': function (event, template) { |
||||
// Make the name editable. We should use an existing component, but it's
|
||||
// in a sorry state - https://github.com/arillo/meteor-x-editable/issues/1
|
||||
var name = template.$('.name'); |
||||
var input = template.$('input'); |
||||
if (input.length) { // jQuery never returns null - http://stackoverflow.com/questions/920236/how-can-i-detect-if-a-selector-returns-null
|
||||
input.show(); |
||||
} else { |
||||
input = $('<input class="form-control" type="text" placeholder="' + this.name + '" style="display: inline">'); |
||||
name.after(input); |
||||
} |
||||
name.hide(); |
||||
input.focus(); |
||||
}, |
||||
'blur input': function (event, template) { |
||||
// commit the change to the name
|
||||
var input = template.$('input'); |
||||
input.hide(); |
||||
template.$('.name').show(); |
||||
// TODO - what is the collection here? We'll hard-code for now.
|
||||
// https://github.com/meteor/meteor/issues/3303
|
||||
Attributes.update(this._id, {$set: {name: input.val()}}); |
||||
} |
||||
}); |
||||
|
||||
// you can add events to all Sortable template instances
|
||||
Template.sortable.events({ |
||||
'click .close': function (event, template) { |
||||
// `this` is the data context set by the enclosing block helper (#each, here)
|
||||
template.collection.remove(this._id); |
||||
} |
||||
}); |
@ -0,0 +1,2 @@
|
||||
Types = new Mongo.Collection('types'); |
||||
Attributes = new Mongo.Collection('attributes'); |
@ -0,0 +1,4 @@
|
||||
mklink ..\..\package.js "meteor/package.js" |
||||
mklink package.json "../../package.json" |
||||
meteor run |
||||
del ..\..\package.js package.json |
@ -0,0 +1,15 @@
|
||||
# sanity check: make sure we're in the root directory of the example |
||||
cd "$( dirname "$0" )" |
||||
|
||||
# delete temp files even if Ctrl+C is pressed |
||||
int_trap() { |
||||
echo "Cleaning up..." |
||||
} |
||||
trap int_trap INT |
||||
|
||||
ln -s "meteor/package.js" ../../package.js |
||||
ln -s "../../package.json" package.json |
||||
|
||||
meteor run "$@" |
||||
|
||||
rm ../../package.js package.json |
@ -0,0 +1,75 @@
|
||||
Meteor.startup(function () { |
||||
if (Types.find().count() === 0) { |
||||
[ |
||||
{ |
||||
name: 'String', |
||||
icon: '<span class="glyphicon glyphicon-tag" aria-hidden="true"></span>' |
||||
}, |
||||
{ |
||||
name: 'Text, multi-line', |
||||
icon: '<i class="mdi-communication-message" aria-hidden="true"></i>' |
||||
}, |
||||
{ |
||||
name: 'Category', |
||||
icon: '<span class="glyphicon glyphicon-list" aria-hidden="true"></span>' |
||||
}, |
||||
{ |
||||
name: 'Number', |
||||
icon: '<i class="mdi-image-looks-one" aria-hidden="true"></i>' |
||||
}, |
||||
{ |
||||
name: 'Date', |
||||
icon: '<span class="glyphicon glyphicon-calendar" aria-hidden="true"></span>' |
||||
}, |
||||
{ |
||||
name: 'Hyperlink', |
||||
icon: '<span class="glyphicon glyphicon-link" aria-hidden="true"></span>' |
||||
}, |
||||
{ |
||||
name: 'Image', |
||||
icon: '<span class="glyphicon glyphicon-picture" aria-hidden="true"></span>' |
||||
}, |
||||
{ |
||||
name: 'Progress', |
||||
icon: '<span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>' |
||||
}, |
||||
{ |
||||
name: 'Duration', |
||||
icon: '<span class="glyphicon glyphicon-time" aria-hidden="true"></span>' |
||||
}, |
||||
{ |
||||
name: 'Map address', |
||||
icon: '<i class="mdi-maps-place" aria-hidden="true"></i>' |
||||
}, |
||||
{ |
||||
name: 'Relationship', |
||||
icon: '<span class="glyphicon glyphicon-flash" aria-hidden="true"></span>' |
||||
} |
||||
].forEach(function (type, i) { |
||||
Types.insert({ |
||||
name: type.name, |
||||
icon: type.icon, |
||||
order: i |
||||
}); |
||||
} |
||||
); |
||||
console.log('Initialized attribute types.'); |
||||
} |
||||
|
||||
if (Attributes.find().count() === 0) { |
||||
[ |
||||
{ name: 'Name', type: 'String' }, |
||||
{ name: 'Created at', type: 'Date' }, |
||||
{ name: 'Link', type: 'Hyperlink' }, |
||||
{ name: 'Owner', type: 'Relationship' } |
||||
].forEach(function (attribute, i) { |
||||
Attributes.insert({ |
||||
name: attribute.name, |
||||
type: attribute.type, |
||||
order: i |
||||
}); |
||||
} |
||||
); |
||||
console.log('Created sample object type.'); |
||||
} |
||||
}); |
@ -0,0 +1,20 @@
|
||||
'use strict'; |
||||
|
||||
Meteor.methods({ |
||||
/** |
||||
* Update the orderField of documents with given ids in a collection, incrementing it by incDec |
||||
* @param {String} collectionName - name of the collection to update |
||||
* @param {String[]} ids - array of document ids |
||||
* @param {String} orderField - the name of the order field, usually "order" |
||||
* @param {Number} incDec - pass 1 or -1 |
||||
*/ |
||||
'rubaxa:sortable/collection-update': function (collectionName, ids, orderField, incDec) { |
||||
check(collectionName, String); |
||||
check(ids, [String]); |
||||
check(orderField, String); |
||||
check(incDec, Number); |
||||
var selector = {_id: {$in: ids}}, modifier = {$inc: {}}; |
||||
modifier.$inc[orderField] = incDec; |
||||
Mongo.Collection.get(collectionName).update(selector, modifier, {multi: true}); |
||||
} |
||||
}); |
@ -1,30 +1,34 @@
|
||||
// package metadata file for Meteor.js
|
||||
'use strict'; |
||||
|
||||
var packageName = 'rubaxa:sortable'; // http://atmospherejs.com/sortable/sortable
|
||||
var where = 'client'; // where to install: 'client', 'server', or ['client', 'server']
|
||||
var packageName = 'rubaxa:sortable'; // http://atmospherejs.com/rubaxa/sortable
|
||||
|
||||
var packageJson = JSON.parse(Npm.require("fs").readFileSync('package.json')); |
||||
|
||||
Package.describe({ |
||||
name: packageName, |
||||
summary: 'Sortable (official): minimalist reorderable drag-and-drop lists on modern browsers and touch devices', |
||||
version: packageJson.version, |
||||
git: 'https://github.com/RubaXa/Sortable.git' |
||||
name: packageName, |
||||
summary: 'Sortable: reactive minimalist reorderable drag-and-drop lists on modern browsers and touch devices', |
||||
version: packageJson.version, |
||||
git: 'https://github.com/RubaXa/Sortable.git', |
||||
readme: 'https://github.com/RubaXa/Sortable/blob/master/meteor/README.md' |
||||
}); |
||||
|
||||
Package.onUse(function (api) { |
||||
api.versionsFrom('METEOR@0.9.0'); |
||||
api.export('Sortable'); |
||||
api.addFiles([ |
||||
'Sortable.js' |
||||
], where |
||||
); |
||||
api.versionsFrom(['METEOR@0.9.0', 'METEOR@1.0']); |
||||
api.use('templating', 'client'); |
||||
api.use('dburles:mongo-collection-instances@0.2.5'); // to watch collections getting created
|
||||
api.export('Sortable'); |
||||
api.addFiles([ |
||||
'Sortable.js', |
||||
'meteor/template.html', // the HTML comes first, so reactivize.js can refer to the template in it
|
||||
'meteor/reactivize.js' |
||||
], 'client'); |
||||
api.addFiles('meteor/methods.js'); // add to both client and server
|
||||
}); |
||||
|
||||
Package.onTest(function (api) { |
||||
api.use(packageName, where); |
||||
api.use('tinytest', where); |
||||
api.use(packageName, 'client'); |
||||
api.use('tinytest', 'client'); |
||||
|
||||
api.addFiles('meteor/test.js', where); |
||||
api.addFiles('meteor/test.js', 'client'); |
||||
}); |
||||
|
@ -1,72 +1,40 @@
|
||||
#!/bin/bash |
||||
# Publish package on Meteor's Atmosphere.js |
||||
# Publish package to Meteor's repository, Atmospherejs.com |
||||
|
||||
# Make sure Meteor is installed, per https://www.meteor.com/install. The curl'ed script is totally safe; takes 2 minutes to read its source and check. |
||||
# Make sure Meteor is installed, per https://www.meteor.com/install. |
||||
# The curl'ed script is totally safe; takes 2 minutes to read its source and check. |
||||
type meteor >/dev/null 2>&1 || { curl https://install.meteor.com/ | sh; } |
||||
|
||||
# sanity check: make sure we're in the root directory of the checkout |
||||
cd "$( dirname "$0" )/.." |
||||
|
||||
ALL_EXIT_CODE=0 |
||||
|
||||
function cleanup() { |
||||
# we copied the file as package.js, regardless of its original name |
||||
rm package.js |
||||
|
||||
# temporary build files |
||||
rm -rf ".build.$PACKAGE_NAME" versions.json |
||||
} |
||||
|
||||
|
||||
# publish separately any package*.js files we have, e.g. package.js, package-compat.js |
||||
# test any package*.js packages we may have, e.g. package.js, package-compat.js |
||||
for PACKAGE_FILE in meteor/package*.js; do |
||||
|
||||
# Meteor expects package.js to be in the root directory of the checkout, so copy there our package file under that name, temporarily |
||||
cp $PACKAGE_FILE ./package.js |
||||
|
||||
# publish package, creating it if it's the first time we're publishing |
||||
PACKAGE_NAME=$(grep -i name $PACKAGE_FILE | head -1 | cut -d "'" -f 2) |
||||
ATMOSPHERE_NAME=${PACKAGE_NAME/://} |
||||
PACKAGE_NAME=$(grep -i name package.js | head -1 | cut -d "'" -f 2) |
||||
|
||||
echo "Publishing $PACKAGE_NAME..." |
||||
|
||||
# attempt to re-publish the package - the most common operation once the initial release has been made |
||||
POTENTIAL_ERROR=$( meteor publish 2>&1 ) |
||||
|
||||
if [[ $POTENTIAL_ERROR =~ "There is no package named" ]]; then |
||||
# actually this is the first time the package is created, so pass the special --create flag and congratulate the maintainer |
||||
echo "Thank you for creating the official Meteor package for this library!" |
||||
if meteor publish --create; then |
||||
echo "Please post the following to https://github.com/raix/Meteor-community-discussions/issues/14: |
||||
|
||||
--------------------------------------------- 8< -------------------------------------------------------- |
||||
|
||||
Happy to announce that I've published the official $PACKAGE_NAME to Atmosphere. Please star! |
||||
https://atmospherejs.com/$ATMOSPHERE_NAME |
||||
|
||||
--------------------------------------------- >8 -------------------------------------------------------- |
||||
|
||||
" |
||||
else |
||||
echo "We got an error. Please post it at https://github.com/raix/Meteor-community-discussions/issues/14" |
||||
cleanup |
||||
exit 1 |
||||
fi |
||||
# Attempt to re-publish the package - the most common operation once the initial release has |
||||
# been made. If the package name was changed (rare), you'll have to pass the --create flag. |
||||
meteor publish "$@"; EXIT_CODE=$? |
||||
ALL_EXIT_CODE=$(( $ALL_EXIT_CODE + $EXIT_CODE )) |
||||
if (( $EXIT_CODE == 0 )); then |
||||
echo "Thanks for releasing a new version. You can see it at" |
||||
echo "https://atmospherejs.com/${PACKAGE_NAME/://}" |
||||
else |
||||
if (( $? > 0 )); then |
||||
# the error wasn't that the package didn't exist, so we need to ask for help |
||||
echo "We got an error. Please post it at https://github.com/raix/Meteor-community-discussions/issues/14: |
||||
--------------------------------------------- 8< -------------------------------------------------------- |
||||
$POTENTIAL_ERROR |
||||
--------------------------------------------- >8 -------------------------------------------------------- |
||||
" |
||||
cleanup |
||||
exit 1 |
||||
else |
||||
echo "Thanks for releasing a new version of $PACKAGE_NAME! You can see it at |
||||
https://atmospherejs.com/$ATMOSPHERE_NAME" |
||||
fi |
||||
echo "We got an error. Please post it at https://github.com/raix/Meteor-community-discussions/issues/14" |
||||
fi |
||||
|
||||
cleanup |
||||
# rm the temporary build files and package.js |
||||
rm -rf ".build.$PACKAGE_NAME" versions.json package.js |
||||
|
||||
done |
||||
|
||||
exit $ALL_EXIT_CODE |
||||
|
@ -0,0 +1,201 @@
|
||||
/* |
||||
Make a Sortable reactive by binding it to a Mongo.Collection. |
||||
Calls `rubaxa:sortable/collection-update` on the server to update the sortField or affected records. |
||||
|
||||
TODO: |
||||
* supply consecutive values if the `order` field doesn't have any |
||||
* .get(DOMElement) - return the Sortable object of a DOMElement |
||||
* create a new _id automatically onAdd if the event.from list had pull: 'clone' |
||||
* support arrays |
||||
* sparse arrays |
||||
* tests |
||||
* drop onto existing empty lists |
||||
* insert back into lists emptied by dropping |
||||
* performance on dragging into long list at the beginning |
||||
* handle failures on Collection operations, e.g. add callback to .insert |
||||
* when adding elements, update ranks just for the half closer to the start/end of the list |
||||
* revisit http://programmers.stackexchange.com/questions/266451/maintain-ordered-collection-by-updating-as-few-order-fields-as-possible
|
||||
* reproduce the insidious bug where the list isn't always sorted (fiddle with dragging #1 over #2, then back, then #N before #1) |
||||
|
||||
*/ |
||||
|
||||
'use strict'; |
||||
|
||||
Template.sortable.created = function () { |
||||
var templateInstance = this; |
||||
// `this` is a template instance that can store properties of our choice - http://docs.meteor.com/#/full/template_inst
|
||||
if (templateInstance.setupDone) return; // paranoid: only run setup once
|
||||
// this.data is the data context - http://docs.meteor.com/#/full/template_data
|
||||
// normalize all options into templateInstance.options, and remove them from .data
|
||||
templateInstance.options = templateInstance.data.options || {}; |
||||
Object.keys(templateInstance.data).forEach(function (key) { |
||||
if (key === 'options' || key === 'items') return; |
||||
templateInstance.options[key] = templateInstance.data[key]; |
||||
delete templateInstance.data[key]; |
||||
}); |
||||
templateInstance.options.sortField = templateInstance.options.sortField || 'order'; |
||||
// We can get the collection via the .collection property of the cursor, but changes made that way
|
||||
// will NOT be sent to the server - https://github.com/meteor/meteor/issues/3271#issuecomment-66656257
|
||||
// Thus we need to use dburles:mongo-collection-instances to get a *real* collection
|
||||
if (templateInstance.data.items && templateInstance.data.items.collection) { |
||||
// cursor passed via items=; its .collection works client-only and has a .name property
|
||||
templateInstance.collectionName = templateInstance.data.items.collection.name; |
||||
templateInstance.collection = Mongo.Collection.get(templateInstance.collectionName); |
||||
} else if (templateInstance.data.items) { |
||||
// collection passed via items=; does NOT have a .name property, but _name
|
||||
templateInstance.collection = templateInstance.data.items; |
||||
templateInstance.collectionName = templateInstance.collection._name; |
||||
} else if (templateInstance.data.collection) { |
||||
// cursor passed directly
|
||||
templateInstance.collectionName = templateInstance.data.collection.name; |
||||
templateInstance.collection = Mongo.Collection.get(templateInstance.collectionName); |
||||
} else { |
||||
templateInstance.collection = templateInstance.data; // collection passed directly
|
||||
templateInstance.collectionName = templateInstance.collection._name; |
||||
} |
||||
|
||||
// TODO if (Array.isArray(templateInstance.collection))
|
||||
|
||||
// What if user filters some of the items in the cursor, instead of ordering the entire collection?
|
||||
// Use case: reorder by preference movies of a given genre, a filter within all movies.
|
||||
// A: Modify all intervening items **that are on the client**, to preserve the overall order
|
||||
// TODO: update *all* orders via a server method that takes not ids, but start & end elements - mild security risk
|
||||
delete templateInstance.data.options; |
||||
|
||||
/** |
||||
* When an element was moved, adjust its orders and possibly the order of |
||||
* other elements, so as to maintain a consistent and correct order. |
||||
* |
||||
* There are three approaches to this: |
||||
* 1) Using arbitrary precision arithmetic and setting only the order of the moved |
||||
* element to the average of the orders of the elements around it - |
||||
* http://programmers.stackexchange.com/questions/266451/maintain-ordered-collection-by-updating-as-few-order-fields-as-possible
|
||||
* The downside is that the order field in the DB will increase by one byte every |
||||
* time an element is reordered. |
||||
* 2) Adjust the orders of the intervening items. This keeps the orders sane (integers) |
||||
* but is slower because we have to modify multiple documents. |
||||
* TODO: we may be able to update fewer records by only altering the |
||||
* order of the records between the newIndex/oldIndex and the start/end of the list. |
||||
* 3) Use regular precision arithmetic, but when the difference between the orders of the |
||||
* moved item and the one before/after it falls below a certain threshold, adjust |
||||
* the order of that other item, and cascade doing so up or down the list. |
||||
* This will keep the `order` field constant in size, and will only occasionally |
||||
* require updating the `order` of other records. |
||||
* |
||||
* For now, we use approach #2. |
||||
* |
||||
* @param {String} itemId - the _id of the item that was moved |
||||
* @param {Number} orderPrevItem - the order of the item before it, or null |
||||
* @param {Number} orderNextItem - the order of the item after it, or null |
||||
*/ |
||||
templateInstance.adjustOrders = function adjustOrders(itemId, orderPrevItem, orderNextItem) { |
||||
var orderField = templateInstance.options.sortField; |
||||
var selector = {}, modifier = {$set: {}}; |
||||
var ids = []; |
||||
var startOrder = templateInstance.collection.findOne(itemId)[orderField]; |
||||
if (orderPrevItem !== null) { |
||||
// Element has a previous sibling, therefore it was moved down in the list.
|
||||
// Decrease the order of intervening elements.
|
||||
selector[orderField] = {$lte: orderPrevItem, $gt: startOrder}; |
||||
ids = _.pluck(templateInstance.collection.find(selector, {fields: {_id: 1}}).fetch(), '_id'); |
||||
Meteor.call('rubaxa:sortable/collection-update', templateInstance.collectionName, ids, orderField, -1); |
||||
|
||||
// Set the order of the dropped element to the order of its predecessor, whose order was decreased
|
||||
modifier.$set[orderField] = orderPrevItem; |
||||
} else { |
||||
// element moved up the list, increase order of intervening elements
|
||||
selector[orderField] = {$gte: orderNextItem, $lt: startOrder}; |
||||
ids = _.pluck(templateInstance.collection.find(selector, {fields: {_id: 1}}).fetch(), '_id'); |
||||
Meteor.call('rubaxa:sortable/collection-update', templateInstance.collectionName, ids, orderField, 1); |
||||
|
||||
// Set the order of the dropped element to the order of its successor, whose order was increased
|
||||
modifier.$set[orderField] = orderNextItem; |
||||
} |
||||
templateInstance.collection.update(itemId, modifier); |
||||
}; |
||||
|
||||
templateInstance.setupDone = true; |
||||
}; |
||||
|
||||
|
||||
Template.sortable.rendered = function () { |
||||
var templateInstance = this; |
||||
var orderField = templateInstance.options.sortField; |
||||
|
||||
// sorting was changed within the list
|
||||
var optionsOnUpdate = templateInstance.options.onUpdate; |
||||
templateInstance.options.onUpdate = function sortableUpdate(/**Event*/event) { |
||||
var itemEl = event.item; // dragged HTMLElement
|
||||
event.data = Blaze.getData(itemEl); |
||||
if (event.newIndex < event.oldIndex) { |
||||
// Element moved up in the list. The dropped element has a next sibling for sure.
|
||||
var orderNextItem = Blaze.getData(itemEl.nextElementSibling)[orderField]; |
||||
templateInstance.adjustOrders(event.data._id, null, orderNextItem); |
||||
} else if (event.newIndex > event.oldIndex) { |
||||
// Element moved down in the list. The dropped element has a previous sibling for sure.
|
||||
var orderPrevItem = Blaze.getData(itemEl.previousElementSibling)[orderField]; |
||||
templateInstance.adjustOrders(event.data._id, orderPrevItem, null); |
||||
} else { |
||||
// do nothing - drag and drop in the same location
|
||||
} |
||||
if (optionsOnUpdate) optionsOnUpdate(event); |
||||
}; |
||||
|
||||
// element was added from another list
|
||||
var optionsOnAdd = templateInstance.options.onAdd; |
||||
templateInstance.options.onAdd = function sortableAdd(/**Event*/event) { |
||||
var itemEl = event.item; // dragged HTMLElement
|
||||
event.data = Blaze.getData(itemEl); |
||||
// let the user decorate the object with additional properties before insertion
|
||||
if (optionsOnAdd) optionsOnAdd(event); |
||||
|
||||
// Insert the new element at the end of the list and move it where it was dropped.
|
||||
// We could insert it at the beginning, but that would lead to negative orders.
|
||||
var sortSpecifier = {}; sortSpecifier[orderField] = -1; |
||||
event.data.order = templateInstance.collection.findOne({}, { sort: sortSpecifier, limit: 1 }).order + 1; |
||||
// TODO: this can obviously be optimized by setting the order directly as the arithmetic average, with the caveats described above
|
||||
var newElementId = templateInstance.collection.insert(event.data); |
||||
event.data._id = newElementId; |
||||
if (itemEl.nextElementSibling) { |
||||
var orderNextItem = Blaze.getData(itemEl.nextElementSibling)[orderField]; |
||||
templateInstance.adjustOrders(newElementId, null, orderNextItem); |
||||
} else { |
||||
// do nothing - inserted after the last element
|
||||
} |
||||
// remove the dropped HTMLElement from the list because we have inserted it in the collection, which will update the template
|
||||
itemEl.parentElement.removeChild(itemEl); |
||||
}; |
||||
|
||||
// element was removed by dragging into another list
|
||||
var optionsOnRemove = templateInstance.options.onRemove; |
||||
templateInstance.options.onRemove = function sortableRemove(/**Event*/event) { |
||||
var itemEl = event.item; // dragged HTMLElement
|
||||
event.data = Blaze.getData(itemEl); |
||||
// don't remove from the collection if group.pull is clone or false
|
||||
if (typeof templateInstance.options.group === 'undefined' |
||||
|| typeof templateInstance.options.group.pull === 'undefined' |
||||
|| templateInstance.options.group.pull === true |
||||
) templateInstance.collection.remove(event.data._id); |
||||
if (optionsOnRemove) optionsOnRemove(event); |
||||
}; |
||||
|
||||
// just compute the `data` context
|
||||
['onStart', 'onEnd', 'onSort', 'onFilter'].forEach(function (eventHandler) { |
||||
if (templateInstance.options[eventHandler]) { |
||||
var userEventHandler = templateInstance.options[eventHandler]; |
||||
templateInstance.options[eventHandler] = function (/**Event*/event) { |
||||
var itemEl = event.item; // dragged HTMLElement
|
||||
event.data = Blaze.getData(itemEl); |
||||
userEventHandler(event); |
||||
}; |
||||
} |
||||
}); |
||||
|
||||
templateInstance.sortable = Sortable.create(templateInstance.firstNode.parentElement, templateInstance.options); |
||||
// TODO make the object accessible, e.g. via Sortable.getSortableById() or some such
|
||||
}; |
||||
|
||||
|
||||
Template.sortable.destroyed = function () { |
||||
this.sortable.destroy(); |
||||
}; |
@ -1,37 +1,46 @@
|
||||
#!/bin/sh |
||||
# Test Meteor package before publishing to Atmospherejs.com |
||||
|
||||
# Make sure Meteor is installed, per https://www.meteor.com/install. The curl'ed script is totally safe; takes 2 minutes to read its source and check. |
||||
# Make sure Meteor is installed, per https://www.meteor.com/install. |
||||
# The curl'ed script is totally safe; takes 2 minutes to read its source and check. |
||||
type meteor >/dev/null 2>&1 || { curl https://install.meteor.com/ | sh; } |
||||
|
||||
# sanity check: make sure we're in the root directory of the checkout |
||||
cd "$( dirname "$0" )/.." |
||||
|
||||
# run tests and delete the temporary package.js even if Ctrl+C is pressed |
||||
|
||||
# delete the temporary files even if Ctrl+C is pressed |
||||
int_trap() { |
||||
echo |
||||
printf "Tests interrupted. Hopefully you verified in the browser that tests pass?\n\n" |
||||
printf "\nTests interrupted. Cleaning up...\n\n" |
||||
} |
||||
|
||||
trap int_trap INT |
||||
|
||||
# test any package*.js packages we may have, e.g. package.js, package-compat.js |
||||
for PACKAGE_FILE in meteor/package*.js; do |
||||
|
||||
PACKAGE_NAME=$(grep -i name $PACKAGE_FILE | head -1 | cut -d "'" -f 2) |
||||
ALL_EXIT_CODE=0 |
||||
|
||||
echo "Testing $PACKAGE_NAME..." |
||||
# test any package*.js packages we may have, e.g. package.js, package-standalone.js |
||||
for PACKAGE_FILE in meteor/package*.js; do |
||||
|
||||
# Meteor expects package.js to be in the root directory of the checkout, so copy there our package file under that name, temporarily |
||||
# Meteor expects package.js in the root dir of the checkout, so copy there our package file under that name, temporarily |
||||
cp $PACKAGE_FILE ./package.js |
||||
|
||||
# provide an invalid MONGO_URL so Meteor doesn't bog us down with an empty Mongo database |
||||
MONGO_URL=mongodb:// meteor test-packages ./ |
||||
PACKAGE_NAME=$(grep -i name package.js | head -1 | cut -d "'" -f 2) |
||||
|
||||
rm -rf ".build.$PACKAGE_NAME" |
||||
rm -rf ".build.local-test:$PACKAGE_NAME" |
||||
rm versions.json 2>/dev/null |
||||
echo "### Testing $PACKAGE_NAME..." |
||||
|
||||
rm package.js |
||||
# provide an invalid MONGO_URL so Meteor doesn't bog us down with an empty Mongo database |
||||
if [ $# -gt 0 ]; then |
||||
# interpret any parameter to mean we want an interactive test |
||||
MONGO_URL=mongodb:// meteor test-packages ./ |
||||
else |
||||
# automated/CI test with phantomjs |
||||
./node_modules/.bin/spacejam --mongo-url mongodb:// test-packages ./ |
||||
ALL_EXIT_CODES=$(( $ALL_EXIT_CODES + $? )) |
||||
fi |
||||
|
||||
# delete temporary build files and package.js |
||||
rm -rf .build.* versions.json package.js |
||||
|
||||
done |
||||
|
||||
exit $ALL_EXIT_CODES |
||||
|
@ -0,0 +1,5 @@
|
||||
<template name="sortable"> |
||||
{{#each items}} |
||||
{{> Template.contentBlock this}} |
||||
{{/each}} |
||||
</template> |
Loading…
Reference in new issue