mirror of https://github.com/RubaXa/Sortable.git
Dan Dascalescu
10 years ago
22 changed files with 682 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 |
# 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 |
||||||
|
@ -0,0 +1,12 @@ |
|||||||
|
# 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 |
||||||
|
dburles:mongo-collection-instances |
||||||
|
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
|
// 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'); |
||||||
}); |
}); |
||||||
|
@ -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 |
||||||
|
@ -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 |
#!/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 |
||||||
|
@ -0,0 +1,5 @@ |
|||||||
|
<template name="sortable"> |
||||||
|
{{#each items}} |
||||||
|
{{> Template.contentBlock this}} |
||||||
|
{{/each}} |
||||||
|
</template> |
Loading…
Reference in new issue