Browse Source

Two-way integration with Meteor collections

pull/177/head
Dan Dascalescu 10 years ago
parent
commit
20ed2ca73c
  1. 13
      Gruntfile.js
  2. 3
      README.md
  3. 95
      meteor/README.md
  4. 1
      meteor/example/.meteor/.gitignore
  5. 11
      meteor/example/.meteor/packages
  6. 1
      meteor/example/.meteor/release
  7. 57
      meteor/example/client/define-object-type.css
  8. 50
      meteor/example/client/define-object-type.html
  9. 78
      meteor/example/client/define-object-type.js
  10. 2
      meteor/example/model.js
  11. 1
      meteor/example/package.json
  12. 1
      meteor/example/packages/Sortable
  13. 4
      meteor/example/run.bat
  14. 15
      meteor/example/run.sh
  15. 75
      meteor/example/server/fixtures.js
  16. 20
      meteor/methods.js
  17. 34
      meteor/package.js
  18. 68
      meteor/publish.sh
  19. 201
      meteor/reactivize.js
  20. 41
      meteor/runtests.sh
  21. 5
      meteor/template.html
  22. 5
      package.json

13
Gruntfile.js

@ -27,7 +27,7 @@ module.exports = function (grunt) {
} }
}, },
shell: { exec: {
'meteor-test': { 'meteor-test': {
command: 'meteor/runtests.sh' command: 'meteor/runtests.sh'
}, },
@ -42,15 +42,12 @@ module.exports = function (grunt) {
grunt.loadNpmTasks('grunt-version'); grunt.loadNpmTasks('grunt-version');
grunt.loadNpmTasks('grunt-contrib-jshint'); grunt.loadNpmTasks('grunt-contrib-jshint');
grunt.loadNpmTasks('grunt-contrib-uglify'); grunt.loadNpmTasks('grunt-contrib-uglify');
grunt.loadNpmTasks('grunt-shell'); grunt.loadNpmTasks('grunt-exec');
// Meteor tasks // Meteor tasks
grunt.registerTask('meteor-test', 'shell:meteor-test'); grunt.registerTask('meteor-test', 'exec:meteor-test');
grunt.registerTask('meteor-publish', 'shell:meteor-publish'); grunt.registerTask('meteor-publish', 'exec:meteor-publish');
// ideally we'd run tests before publishing, but the chances of tests breaking (given that grunt.registerTask('meteor', ['meteor-test', 'meteor-publish']);
// Meteor is orthogonal to the library) are so small that it's not worth the maintainer's time
// grunt.regsterTask('meteor', ['shell:meteor-test', 'shell:meteor-publish']);
grunt.registerTask('meteor', 'shell:meteor-publish');
grunt.registerTask('tests', ['jshint']); grunt.registerTask('tests', ['jshint']);
grunt.registerTask('default', ['tests', 'version', 'uglify']); grunt.registerTask('default', ['tests', 'version', 'uglify']);

3
README.md

@ -12,7 +12,8 @@ Demo: http://rubaxa.github.io/Sortable/
* Supports drag handles *and selectable text* (better than voidberg's html5sortable) * Supports drag handles *and selectable text* (better than voidberg's html5sortable)
* Smart auto-scrolling * Smart auto-scrolling
* Built using native HTML5 drag and drop API * Built using native HTML5 drag and drop API
* Supports [AngularJS](#ng) and and any CSS library, e.g. [Bootstrap](#bs) * Supports [Meteor](meteor/README.md) and [AngularJS](#ng)
* Supports any CSS library, e.g. [Bootstrap](#bs)
* Simple API * Simple API
* No jQuery * No jQuery

95
meteor/README.md

@ -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 # Meteor
If you're new to Meteor, here's what the excitement is all about - 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. [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 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). 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 # TODO
* Meteor collection backing * Array support
* Tests ensuring correct rendering with Meteor dynamic templates * Tests
* Misc. - see reactivize.js

1
meteor/example/.meteor/.gitignore vendored

@ -0,0 +1 @@
local

11
meteor/example/.meteor/packages

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

1
meteor/example/.meteor/release

@ -0,0 +1 @@
METEOR@1.0.1

57
meteor/example/client/define-object-type.css

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

50
meteor/example/client/define-object-type.html

@ -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">&times;</span><span class="sr-only">Close</span>
</button>
</div>
</template>

78
meteor/example/client/define-object-type.js

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

2
meteor/example/model.js

@ -0,0 +1,2 @@
Types = new Mongo.Collection('types');
Attributes = new Mongo.Collection('attributes');

1
meteor/example/package.json

@ -0,0 +1 @@
../../package.json

1
meteor/example/packages/Sortable

@ -0,0 +1 @@
../../../

4
meteor/example/run.bat

@ -0,0 +1,4 @@
mklink ..\..\package.js "meteor/package.js"
mklink package.json "../../package.json"
meteor run
del ..\..\package.js package.json

15
meteor/example/run.sh

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

75
meteor/example/server/fixtures.js

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

20
meteor/methods.js

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

34
meteor/package.js

@ -1,30 +1,34 @@
// package metadata file for Meteor.js // package metadata file for Meteor.js
'use strict'; 'use strict';
var packageName = 'rubaxa:sortable'; // http://atmospherejs.com/sortable/sortable var packageName = 'rubaxa:sortable'; // http://atmospherejs.com/rubaxa/sortable
var where = 'client'; // where to install: 'client', 'server', or ['client', 'server']
var packageJson = JSON.parse(Npm.require("fs").readFileSync('package.json')); var packageJson = JSON.parse(Npm.require("fs").readFileSync('package.json'));
Package.describe({ Package.describe({
name: packageName, name: packageName,
summary: 'Sortable (official): minimalist reorderable drag-and-drop lists on modern browsers and touch devices', summary: 'Sortable: reactive minimalist reorderable drag-and-drop lists on modern browsers and touch devices',
version: packageJson.version, version: packageJson.version,
git: 'https://github.com/RubaXa/Sortable.git' git: 'https://github.com/RubaXa/Sortable.git',
readme: 'https://github.com/RubaXa/Sortable/blob/master/meteor/README.md'
}); });
Package.onUse(function (api) { Package.onUse(function (api) {
api.versionsFrom('METEOR@0.9.0'); api.versionsFrom(['METEOR@0.9.0', 'METEOR@1.0']);
api.export('Sortable'); api.use('templating', 'client');
api.addFiles([ api.use('dburles:mongo-collection-instances@0.2.5'); // to watch collections getting created
'Sortable.js' api.export('Sortable');
], where 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) { Package.onTest(function (api) {
api.use(packageName, where); api.use(packageName, 'client');
api.use('tinytest', where); api.use('tinytest', 'client');
api.addFiles('meteor/test.js', where); api.addFiles('meteor/test.js', 'client');
}); });

68
meteor/publish.sh

@ -1,72 +1,40 @@
#!/bin/bash #!/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; } 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 # sanity check: make sure we're in the root directory of the checkout
cd "$( dirname "$0" )/.." cd "$( dirname "$0" )/.."
ALL_EXIT_CODE=0
function cleanup() { # test any package*.js packages we may have, e.g. package.js, package-compat.js
# 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
for PACKAGE_FILE in meteor/package*.js; do 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 to be in the root directory of the checkout, so copy there our package file under that name, temporarily
cp $PACKAGE_FILE ./package.js cp $PACKAGE_FILE ./package.js
# publish package, creating it if it's the first time we're publishing # 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) PACKAGE_NAME=$(grep -i name package.js | head -1 | cut -d "'" -f 2)
ATMOSPHERE_NAME=${PACKAGE_NAME/://}
echo "Publishing $PACKAGE_NAME..." echo "Publishing $PACKAGE_NAME..."
# attempt to re-publish the package - the most common operation once the initial release has been made # Attempt to re-publish the package - the most common operation once the initial release has
POTENTIAL_ERROR=$( meteor publish 2>&1 ) # been made. If the package name was changed (rare), you'll have to pass the --create flag.
meteor publish "$@"; EXIT_CODE=$?
if [[ $POTENTIAL_ERROR =~ "There is no package named" ]]; then ALL_EXIT_CODE=$(( $ALL_EXIT_CODE + $EXIT_CODE ))
# actually this is the first time the package is created, so pass the special --create flag and congratulate the maintainer if (( $EXIT_CODE == 0 )); then
echo "Thank you for creating the official Meteor package for this library!" echo "Thanks for releasing a new version. You can see it at"
if meteor publish --create; then echo "https://atmospherejs.com/${PACKAGE_NAME/://}"
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
else else
if (( $? > 0 )); then echo "We got an error. Please post it at https://github.com/raix/Meteor-community-discussions/issues/14"
# 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
fi fi
cleanup # rm the temporary build files and package.js
rm -rf ".build.$PACKAGE_NAME" versions.json package.js
done done
exit $ALL_EXIT_CODE

201
meteor/reactivize.js

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

41
meteor/runtests.sh

@ -1,37 +1,46 @@
#!/bin/sh #!/bin/sh
# Test Meteor package before publishing to Atmospherejs.com # 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; } 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 # sanity check: make sure we're in the root directory of the checkout
cd "$( dirname "$0" )/.." 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() { int_trap() {
echo printf "\nTests interrupted. Cleaning up...\n\n"
printf "Tests interrupted. Hopefully you verified in the browser that tests pass?\n\n"
} }
trap int_trap INT 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 cp $PACKAGE_FILE ./package.js
# provide an invalid MONGO_URL so Meteor doesn't bog us down with an empty Mongo database PACKAGE_NAME=$(grep -i name package.js | head -1 | cut -d "'" -f 2)
MONGO_URL=mongodb:// meteor test-packages ./
rm -rf ".build.$PACKAGE_NAME" echo "### Testing $PACKAGE_NAME..."
rm -rf ".build.local-test:$PACKAGE_NAME"
rm versions.json 2>/dev/null
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 done
exit $ALL_EXIT_CODES

5
meteor/template.html

@ -0,0 +1,5 @@
<template name="sortable">
{{#each items}}
{{> Template.contentBlock this}}
{{/each}}
</template>

5
package.json

@ -5,9 +5,10 @@
"devDependencies": { "devDependencies": {
"grunt": "*", "grunt": "*",
"grunt-version": "*", "grunt-version": "*",
"grunt-shell": "*", "grunt-exec": "*",
"grunt-contrib-jshint": "0.9.2", "grunt-contrib-jshint": "0.9.2",
"grunt-contrib-uglify": "*" "grunt-contrib-uglify": "*",
"spacejam": "*"
}, },
"description": "Minimalist library for reorderable drag-and-drop lists on modern browsers and touch devices. No jQuery.", "description": "Minimalist library for reorderable drag-and-drop lists on modern browsers and touch devices. No jQuery.",
"main": "Sortable.js", "main": "Sortable.js",

Loading…
Cancel
Save