From 20ed2ca73c8b02355449d53938ee3be2bbdd4d6f Mon Sep 17 00:00:00 2001 From: Dan Dascalescu Date: Wed, 17 Dec 2014 10:31:53 -0800 Subject: [PATCH] Two-way integration with Meteor collections --- Gruntfile.js | 13 +- README.md | 3 +- meteor/README.md | 95 ++++++++- meteor/example/.meteor/.gitignore | 1 + meteor/example/.meteor/packages | 11 + meteor/example/.meteor/release | 1 + meteor/example/client/define-object-type.css | 57 +++++ meteor/example/client/define-object-type.html | 50 +++++ meteor/example/client/define-object-type.js | 78 +++++++ meteor/example/model.js | 2 + meteor/example/package.json | 1 + meteor/example/packages/Sortable | 1 + meteor/example/run.bat | 4 + meteor/example/run.sh | 15 ++ meteor/example/server/fixtures.js | 75 +++++++ meteor/methods.js | 20 ++ meteor/package.js | 34 +-- meteor/publish.sh | 68 ++---- meteor/reactivize.js | 201 ++++++++++++++++++ meteor/runtests.sh | 41 ++-- meteor/template.html | 5 + package.json | 5 +- 22 files changed, 681 insertions(+), 100 deletions(-) create mode 100644 meteor/example/.meteor/.gitignore create mode 100644 meteor/example/.meteor/packages create mode 100644 meteor/example/.meteor/release create mode 100644 meteor/example/client/define-object-type.css create mode 100644 meteor/example/client/define-object-type.html create mode 100644 meteor/example/client/define-object-type.js create mode 100644 meteor/example/model.js create mode 120000 meteor/example/package.json create mode 120000 meteor/example/packages/Sortable create mode 100755 meteor/example/run.bat create mode 100755 meteor/example/run.sh create mode 100644 meteor/example/server/fixtures.js create mode 100644 meteor/methods.js create mode 100644 meteor/reactivize.js create mode 100644 meteor/template.html diff --git a/Gruntfile.js b/Gruntfile.js index 4897fb4..64cfc70 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -27,7 +27,7 @@ module.exports = function (grunt) { } }, - shell: { + exec: { 'meteor-test': { command: 'meteor/runtests.sh' }, @@ -42,15 +42,12 @@ module.exports = function (grunt) { grunt.loadNpmTasks('grunt-version'); grunt.loadNpmTasks('grunt-contrib-jshint'); grunt.loadNpmTasks('grunt-contrib-uglify'); - grunt.loadNpmTasks('grunt-shell'); + grunt.loadNpmTasks('grunt-exec'); // Meteor tasks - grunt.registerTask('meteor-test', 'shell:meteor-test'); - grunt.registerTask('meteor-publish', 'shell:meteor-publish'); - // ideally we'd run tests before publishing, but the chances of tests breaking (given that - // 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('meteor-test', 'exec:meteor-test'); + grunt.registerTask('meteor-publish', 'exec:meteor-publish'); + grunt.registerTask('meteor', ['meteor-test', 'meteor-publish']); grunt.registerTask('tests', ['jshint']); grunt.registerTask('default', ['tests', 'version', 'uglify']); diff --git a/README.md b/README.md index 9a328e8..fc2e4ff 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ Demo: http://rubaxa.github.io/Sortable/ * Supports drag handles *and selectable text* (better than voidberg's html5sortable) * Smart auto-scrolling * 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 * No jQuery diff --git a/meteor/README.md b/meteor/README.md index 49c1118..a9cf729 100644 --- a/meteor/README.md +++ b/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 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 }} +``` + +Persist the sort order in the 'order' field of each document in the collection: + +```handlebars +{{sortable items= 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= option1=value1 option2=value2...}} + {{sortable items= 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 + -* 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 diff --git a/meteor/example/.meteor/.gitignore b/meteor/example/.meteor/.gitignore new file mode 100644 index 0000000..4083037 --- /dev/null +++ b/meteor/example/.meteor/.gitignore @@ -0,0 +1 @@ +local diff --git a/meteor/example/.meteor/packages b/meteor/example/.meteor/packages new file mode 100644 index 0000000..26940a1 --- /dev/null +++ b/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 diff --git a/meteor/example/.meteor/release b/meteor/example/.meteor/release new file mode 100644 index 0000000..f1b6255 --- /dev/null +++ b/meteor/example/.meteor/release @@ -0,0 +1 @@ +METEOR@1.0.1 diff --git a/meteor/example/client/define-object-type.css b/meteor/example/client/define-object-type.css new file mode 100644 index 0000000..d67b4b1 --- /dev/null +++ b/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; +} diff --git a/meteor/example/client/define-object-type.html b/meteor/example/client/define-object-type.html new file mode 100644 index 0000000..f8c23d2 --- /dev/null +++ b/meteor/example/client/define-object-type.html @@ -0,0 +1,50 @@ + + RubaXa:Sortable Demo + + + +
+ + + {{> typeDefinition}} +
+ + + + + diff --git a/meteor/example/client/define-object-type.js b/meteor/example/client/define-object-type.js new file mode 100644 index 0000000..9060cbd --- /dev/null +++ b/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 = $(''); + 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); + } +}); diff --git a/meteor/example/model.js b/meteor/example/model.js new file mode 100644 index 0000000..8ae8822 --- /dev/null +++ b/meteor/example/model.js @@ -0,0 +1,2 @@ +Types = new Mongo.Collection('types'); +Attributes = new Mongo.Collection('attributes'); diff --git a/meteor/example/package.json b/meteor/example/package.json new file mode 120000 index 0000000..138a42c --- /dev/null +++ b/meteor/example/package.json @@ -0,0 +1 @@ +../../package.json \ No newline at end of file diff --git a/meteor/example/packages/Sortable b/meteor/example/packages/Sortable new file mode 120000 index 0000000..1b20c9f --- /dev/null +++ b/meteor/example/packages/Sortable @@ -0,0 +1 @@ +../../../ \ No newline at end of file diff --git a/meteor/example/run.bat b/meteor/example/run.bat new file mode 100755 index 0000000..a6d845a --- /dev/null +++ b/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 diff --git a/meteor/example/run.sh b/meteor/example/run.sh new file mode 100755 index 0000000..0d93b80 --- /dev/null +++ b/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 diff --git a/meteor/example/server/fixtures.js b/meteor/example/server/fixtures.js new file mode 100644 index 0000000..617acf9 --- /dev/null +++ b/meteor/example/server/fixtures.js @@ -0,0 +1,75 @@ +Meteor.startup(function () { + if (Types.find().count() === 0) { + [ + { + name: 'String', + icon: '' + }, + { + name: 'Text, multi-line', + icon: '' + }, + { + name: 'Category', + icon: '' + }, + { + name: 'Number', + icon: '' + }, + { + name: 'Date', + icon: '' + }, + { + name: 'Hyperlink', + icon: '' + }, + { + name: 'Image', + icon: '' + }, + { + name: 'Progress', + icon: '' + }, + { + name: 'Duration', + icon: '' + }, + { + name: 'Map address', + icon: '' + }, + { + name: 'Relationship', + icon: '' + } + ].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.'); + } +}); diff --git a/meteor/methods.js b/meteor/methods.js new file mode 100644 index 0000000..fe8d834 --- /dev/null +++ b/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}); + } +}); diff --git a/meteor/package.js b/meteor/package.js index 192bca0..823a0ac 100644 --- a/meteor/package.js +++ b/meteor/package.js @@ -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'); }); diff --git a/meteor/publish.sh b/meteor/publish.sh index 9d31c42..56cd665 100755 --- a/meteor/publish.sh +++ b/meteor/publish.sh @@ -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 diff --git a/meteor/reactivize.js b/meteor/reactivize.js new file mode 100644 index 0000000..34ff501 --- /dev/null +++ b/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(); +}; diff --git a/meteor/runtests.sh b/meteor/runtests.sh index 400eee9..94aaecf 100755 --- a/meteor/runtests.sh +++ b/meteor/runtests.sh @@ -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 diff --git a/meteor/template.html b/meteor/template.html new file mode 100644 index 0000000..3923d3d --- /dev/null +++ b/meteor/template.html @@ -0,0 +1,5 @@ + diff --git a/package.json b/package.json index 913e40a..2d5710e 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,10 @@ "devDependencies": { "grunt": "*", "grunt-version": "*", - "grunt-shell": "*", + "grunt-exec": "*", "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.", "main": "Sortable.js",