Initial commit. Modified the Meteor todos app to create the Petit Teton data tracking app. Has working data for sales. Requires a Mongo database.

This commit is contained in:
Wynne Crisman
2017-01-15 11:33:37 -08:00
commit b757595cd6
104 changed files with 26824 additions and 0 deletions

130
imports/api/Measure.js Normal file
View File

@@ -0,0 +1,130 @@
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { check } from 'meteor/check';
import {SimpleSchema} from 'meteor/aldeed:simple-schema';
Measures = new Mongo.Collection('Measures');
Measures.attachSchema(new SimpleSchema({
name: {
type: String,
label: "Name",
optional: false,
trim: true,
index: 1,
unique: true
},
postfix: {
type: String,
label: "Postfix",
optional: true, //Note: Each does not have a postfix.
trim: true
},
order: {
type: SimpleSchema.Integer,
label: "Order",
optional: false,
min: 0
},
createdAt: { //Force the value to the current date on the server.
type: Date,
label: "Created On",
// autoValue: function() { //The disadvantage of autoValue is that it sets the date after insertion, causing the UI to update twice - once where the item has no date, and then again where the date is set. Sorted lists will cause the item to bounce around.
// if(this.isInsert) return new Date();
// else if(this.isUpsert) return {$setOnInsert: new Date()};
// else this.unset();
// },
// denyUpdate: true,
optional: false
},
updatedAt: {
type: Date,
label: "Updated On",
// autoValue: function() {
// if(this.isUpdate) return new Date();
// },
// denyInsert: true,
optional: true
},
deletedAt: {
type: Date,
label: "Deleted On",
optional: true
},
deletedBy: {
type: String,
label: "Deleted By",
optional: true
},
restoredAt: {
type: Date,
label: "Restored On",
optional: true
},
restoredBy: {
type: String,
label: "Restored By",
optional: true
}
}));
//https://github.com/zimme/meteor-collection-softremovable
Measures.attachBehaviour("softRemovable", {
removed: 'deleted',
removedAt: 'deletedAt',
removedBy: 'removedBy',
restoredAt: 'restoredAt',
restoredBy: 'restoredBy'
});
if(Meteor.isServer) Meteor.publish('measures', function() {
return Measures.find({});
});
// Requires: meteor add matb33:collection-hooks
Measures.before.insert(function(userId, doc) {
// check(userId, String);
doc.createdAt = new Date();
});
Measures.before.update(function(userId, doc, fieldNames, modifier, options) {
modifier.$set = modifier.$set || {}; //Make sure there is an object.
modifier.$set.updatedAt = new Date();
});
if(Meteor.isServer) {
Meteor.methods({
insertMeasure: function(measure) {
check(measure, {
name: String,
order: Number,
postfix: String
});
measure.createdAt = new Date();
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
Measures.insert(measure);
}
else throw new Meteor.Error(403, "Not authorized.");
},
deleteMeasure: function(id) {
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
Measures.remove(id);
}
else throw new Meteor.Error(403, "Not authorized.");
},
updateMeasure: function(measure) {
check(measure, {
name: String,
order: Number,
postfix: String
});
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
Products.update(id, {$set: {name: measure.name, order: measure.order, postfix: measure.postfix, updateAt: new Date()}});
}
else throw new Meteor.Error(403, "Not authorized.");
}
});
}
export default Measures;

268
imports/api/Product.js Normal file
View File

@@ -0,0 +1,268 @@
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { check } from 'meteor/check';
import {SimpleSchema} from 'meteor/aldeed:simple-schema';
Products = new Mongo.Collection('Products');
const ProductsSchema = new SimpleSchema({
name: {
type: String,
label: "Name",
optional: false,
trim: true,
index: 1,
unique: true
},
tags: { //An array of ProductTag names. Note that we are not using the ProductTag ID's because I want a looser connection (if a ProductTag is deleted, it isn't a big deal if it isn't maintained in the Product records).
type: [String],
label: "Tags",
optional: false,
defaultValue: []
},
measures: { //A JSON array of Measure ID's.
type: Array,
label: "Measures",
optional: false,
defaultValue: []
},
'measures.$': {
type: String,
label: "Measure ID",
regEx: SimpleSchema.RegEx.Id
},
aliases: { //A JSON array of alternate names.
type: Array,
label: "Aliases",
optional: false,
defaultValue: []
},
'aliases.$': {
type: String
},
prices: { //A JSON object mapping Measure ID's to price data for this item. Price data is an object with price, effectiveDate, previousPrice. Example: prices: {XZ5Z3CM49NDrJNADA: {price: 10.5, effectiveDate: ISODate("2017-01-12T13:14:18.876-08:00"), previousPrice: 9}, ...}
type: Object,
//blackbox: true,
custom: function() {
console.log("In custom validation for prices");
return true;
// if(this.value != undefined) {
// console.log(this.value);
// //check(this, Object);
// if(!_.isObject(this.value)) {
// return "expectObject";
// }
//
// for(let measureId of _.allKeys(this.value)) {
// //check(measureId, String); //Should be a Mongo ID
// if(!_.isString(measureId)) {
// console.log("Expected a Mongo ID as attribute names of the Product.prices object.");
// return "expectString";
// }
// //check(this.value[measureId], Object);
// let measureData = this.value[measureId];
// if(!_.isObject(measureData)) {
// console.log("Expected an Object containing price, and optionally (previousPrice & effectiveDate).");
// return "expectObject";
// }
//
// if(_.has(measureData, "price")) {
// //check(measureData.price, Number);
// if(!_.isNumber(measureData.price)) {
// console.log("Expected a Number for 'price'.");
// return "expectNumber";
// }
//
// //If previous price exists then it must be a number, and effective date must exist and be a date.
// if(_.has(measureData, "previousPrice")) {
// //check(measureData.effectiveDate, Date);
// if(!_.isDate(measureData.effectiveDate)) {
// console.log("Expected a Date for 'effectiveDate'.");
// return "expectDate";
// }
// //check(measureData.previousPrice, Number);
// if(!_.isDate(measureData.previousPrice)) {
// console.log("Expected a Number for 'previousPrice'.");
// return "expectNumber";
// }
// }
// else {
// //check(measureData.effectiveDate, undefined);
// if(_.isSet(measureData.effectiveDate)) {
// console.log("Expected 'effectiveDate' to be undefined.");
// return "notAllowed";
// }
// }
// }
// else {
// //check(measureData.effectiveDate, undefined);
// if(_.isSet(measureData.effectiveDate)) {
// console.log("Expected 'effectiveDate' to be undefined.");
// return "notAllowed";
// }
// //check(measureData.previousPrice, undefined);
// if(_.isSet(measureData.previousPrice)) {
// console.log("Expected 'previousPrice' to be undefined.");
// return "notAllowed";
// }
// }
// }
// }
}
},
createdAt: {
type: Date,
label: "Created On",
optional: false
},
updatedAt: {
type: Date,
label: "Updated On",
optional: true
},
deletedAt: {
type: Date,
label: "Deleted On",
optional: true
},
deletedBy: {
type: String,
label: "Deleted By",
optional: true
},
restoredAt: {
type: Date,
label: "Restored On",
optional: true
},
restoredBy: {
type: String,
label: "Restored By",
optional: true
}
});
//Note: Could not figure out how to setup a schema for prices where prices is an object whose keys are id's of measures, and whose values are objects containing price data (see comments above for more details on price data).
// 'prices.$': {
// type: new SimpleSchema({
// measureId: {
// type: String,
// label: "Measure Id",
// trim: false,
// regEx: SimpleSchema.RegEx.Id,
// optional: false
// },
// price: {
// type: Number,
// label: "Price",
// min: 0,
// exclusiveMin: true,
// optional: false
// }
// })
// },
//Products.attachSchema(ProductsSchema);
//https://github.com/zimme/meteor-collection-softremovable
Products.attachBehaviour("softRemovable", {
removed: 'deleted',
removedAt: 'deletedAt',
removedBy: 'removedBy',
restoredAt: 'restoredAt',
restoredBy: 'restoredBy'
});
if(Meteor.isServer) {
Meteor.publish('products', function() {
// let dbQuery = {};
//
// if(query) {
// _.each(_.keys(query), function(key) {
// if(_.isObject(query[key])) dbQuery[key] = query[key];
// else if(_.isNumber(query[key])) dbQuery[key] = query[key];
// else dbQuery[key] = {$regex: RegExp.escape(query[key]), $options: 'i'};
// })
// }
//
// return Products.find(dbQuery, {sort: {date: -1}});
return Products.find({}, {sort: {name: 1}});
});
Meteor.methods({
insertProduct: function(product) {
check(product, {
name: String
});
product.createdAt = new Date();
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
Products.insert(product, function(err, id) {
if(err) console.log(err);
});
}
else throw new Meteor.Error(403, "Not authorized.");
},
deleteProduct: function(id) {
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
Products.remove(id);
}
else throw new Meteor.Error(403, "Not authorized.");
},
updateProduct: function(product) {
check(product, {
_id: String,
name: String
});
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
Products.update(id, {$set: {name: product.name, updateAt: new Date()}});
}
else throw new Meteor.Error(403, "Not authorized.");
},
setProductPrice: function(productId, measureId, price, setPrevious, effectiveDate) {
check(productId, String);
check(measureId, String);
check(price, Number);
check(setPrevious, Boolean);
check(effectiveDate, Date);
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
let product = Products.findOne(productId, {fields: {prices: 1}});
if(product) {
console.log("Product: " + JSON.stringify(product));
let prices = product.prices ? product.prices : {};
let measurePriceData = prices[measureId];
if(!measurePriceData) {
measurePriceData = {};
prices[measureId] = measurePriceData;
}
console.log("Old Price Data: " + JSON.stringify(prices));
if(setPrevious && measurePriceData.price) {
measurePriceData.previousPrice = measurePriceData.price;
measurePriceData.effectiveDate = effectiveDate;
}
measurePriceData.price = price;
console.log("New Price Data: " + JSON.stringify(prices));
//console.log(ProductsSchema.validate(product));
//
// check(prices, ProductsSchema);
if(ProductsSchema.newContext().isValid()) {
console.log("Valid schema for product");
Products.update(productId, {$set: {prices: prices, updateAt: new Date()}}, {validate: false, bypassCollection2: true});
}
else console.log("Invalid schema for product");
}
else throw new Meteor.ERROR(400, "Could not find the requested product: " + productId);
}
else throw new Meteor.Error(403, "Not authorized.");
}
});
}
export default Products;

74
imports/api/ProductTag.js Normal file
View File

@@ -0,0 +1,74 @@
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { check } from 'meteor/check'
import {SimpleSchema} from 'meteor/aldeed:simple-schema';
ProductTags = new Mongo.Collection('ProductTags', {
schema: new SimpleSchema({
name: {
type: String,
label: "Name",
optional: false,
trim: true,
index: 1,
unique: true
},
createdAt: {
type: Date,
label: "Created On",
optional: false
},
updatedAt: {
type: Date,
label: "Updated On",
optional: true
}
})
});
//Allows the client to do DB interaction without calling server side methods, while still retaining control over whether the user can make changes.
ProductTags.allow({
insert: function() {return false;},
update: function() {return false;},
remove: function() {return false;}
});
if(Meteor.isServer) {
Meteor.publish('productTags', function() {
return ProductTags.find({});
});
Meteor.methods({
insertProductTag: function(productTag) {
check(productTag, String);
productTag = {name: productTag, createdAt: new Date()};
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
ProductTags.insert(productTag, function(err, id) {
if(err) console.log(err);
});
}
else throw new Meteor.Error(403, "Not authorized.");
},
deleteProductTag: function(id) {
check(id, String);
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
ProductTags.remove(id);
}
else throw new Meteor.Error(403, "Not authorized.");
},
updateProductTag: function(tag) {
check(tag, {
_id: String,
name: String
});
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
ProductTags.update(tag._id, {$set: {name: tag.name, updateAt: new Date()}});
}
else throw new Meteor.Error(403, "Not authorized.");
}
});
}
export default ProductTags;

17
imports/api/Roles.js Normal file
View File

@@ -0,0 +1,17 @@
if(Meteor.isServer) {
Meteor.publish('roles', function() {
if(Roles.userIsInRole(this.userId, ['manage'])) {
return Meteor.roles.find({}, {fields: {name: 1}});
}
else throw new Meteor.Error(403, "Not authorized to view roles.");
});
}
let ROLE_MANAGE = "manage";
let ROLE_UPDATE = "update";
Meteor.UserRoles = {ROLE_MANAGE, ROLE_UPDATE};
export default Meteor.roles;

111
imports/api/Sale.js Normal file
View File

@@ -0,0 +1,111 @@
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { check } from 'meteor/check';
import {SimpleSchema} from 'meteor/aldeed:simple-schema';
Sales = new Mongo.Collection('Sales');
let SalesSchema = new SimpleSchema({
date: {
type: Date,
label: "Date",
optional: false,
index: 1
},
amount: {
type: Number,
label: "Amount",
optional: false,
decimal: true
},
price: {
type: Number,
label: "Price",
optional: false,
min: 0,
exclusiveMin: true,
decimal: true
},
measureId: {
type: String,
label: "Measure Id",
trim: false,
regEx: SimpleSchema.RegEx.Id,
index: 1
},
productId: {
type: String,
label: "Product Id",
trim: false,
regEx: SimpleSchema.RegEx.Id,
index: 1
},
venueId: {
type: String,
label: "Vendor Id",
trim: false,
regEx: SimpleSchema.RegEx.Id,
index: 1
// autoform: {
// type: 'relation',
// settings: {
// collection: 'Venues',
// fields: ['name']
// }
// }
},
createdAt: {
type: Date,
label: "Created On",
optional: false
}
});
Sales.attachSchema(SalesSchema);
if(Meteor.isServer) {
Meteor.publish('sales', function(query, limit = 100) {
let dbQuery = {};
if(query) {
_.each(_.keys(query), function(key) {
if(_.isObject(query[key])) dbQuery[key] = query[key];
else if(_.isNumber(query[key])) dbQuery[key] = query[key];
else dbQuery[key] = {$regex: RegExp.escape(query[key]), $options: 'i'};
})
}
if(!_.isNumber(limit)) limit = 100;
return Meteor.collections.Sales.find(dbQuery, {limit: limit, sort: {date: -1}});
});
Meteor.methods({
insertSale: function(sale) {
//TODO: Check the structure of sale. Use: check(sale, {name: String, ...});
sale.createdAt = new Date();
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
Sales.insert(sale, function(err, id) {
if(err) console.log(err);
});
}
else throw new Meteor.Error(403, "Not authorized.");
},
deleteSale: function(id) {
check(id, String);
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
Sales.remove(id);
}
else throw new Meteor.Error(403, "Not authorized.");
}
});
}
//Allows the client to do DB interaction without calling server side methods, while still retaining control over whether the user can make changes.
Sales.allow({
insert: function() {return false;},
update: function() {return false;},
remove: function() {return false;}
});
export default Sales;

69
imports/api/User.js Normal file
View File

@@ -0,0 +1,69 @@
import {Random} from 'meteor/random';
if(Meteor.isServer) {
Meteor.publish('users', function() {
if(Roles.userIsInRole(this.userId, ['manage'])) {
return Meteor.users.find({}, {fields: {username: 1, emails: 1, roles: 1}});
}
else throw new Meteor.Error(403, "Not authorized to view users.");
});
Meteor.methods({
"insertUser": function(user, roles) {
check(user, {
username: String,
email: String
});
check(roles, [String]);
//Verify the currently logged in user has authority to manage users.
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_MANAGE])) {
//Verify the user name isn't already used.
if(Meteor.collections.Users.findOne({username: user.username}) == undefined) {
let pwd = Random.secret(20);
let id = Accounts.createUser({password: pwd, username: user.username, email: user.email});
//Requires the alanning:roles package.
Roles.addUsersToRoles(id, roles);
}
else {
throw new Meteor.Error(400, "User already exists.");
}
}
else throw new Meteor.Error(403, "Not authorized to add users.");
},
"updateUser": function(user) {
check(user, {
_id: String,
username: String,
emails: [{
address: String,
verified: Boolean
}],
roles: [String]
});
//Verify the currently logged in user has authority to manage users.
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_MANAGE])) {
//Verify the user name isn't already used with a different ID.
if(Meteor.collections.Users.findOne({username: user.username, _id: {$ne: user._id}}) == undefined) {
//Update the user. Note: I am using direct mongo modification, versus attempting to go through the Accounts and Roles objects. This could cause problems in the future if these packages change their data structures.
Meteor.collections.Users.update(user._id, {$set: {username: user.username, emails: user.emails, roles: user.roles}});
}
else {
throw new Meteor.Error(400, "User name already exists.");
}
}
else throw new Meteor.Error(403, "Not authorized to update users.");
},
"deleteUser": function(id) {
check(id, String);
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_MANAGE])) {
Meteor.collections.Users.remove(id);
}
else throw new Meteor.Error(403, "Not authorized to remove users.");
}
});
}
export default Meteor.users;

130
imports/api/Venue.js Normal file
View File

@@ -0,0 +1,130 @@
import { Meteor } from 'meteor/meteor';
import { Mongo } from 'meteor/mongo';
import { check } from 'meteor/check';
import {SimpleSchema} from 'meteor/aldeed:simple-schema';
Venues = new Mongo.Collection('Venues');
let VenuesSchema = new SimpleSchema({
name: {
type: String,
label: "Name",
optional: false,
trim: true,
index: 1,
unique: true
},
type: {
type: String,
label: "Type",
optional: false,
trim: true
},
createdAt: {
type: Date,
label: "Created On",
optional: false
},
updatedAt: {
type: Date,
label: "Updated On",
optional: true
},
deletedAt: {
type: Date,
label: "Deleted On",
optional: true
},
deletedBy: {
type: String,
label: "Deleted By",
optional: true
},
restoredAt: {
type: Date,
label: "Restored On",
optional: true
},
restoredBy: {
type: String,
label: "Restored By",
optional: true
}
});
Venues.attachSchema(VenuesSchema);
//https://github.com/zimme/meteor-collection-softremovable
Venues.attachBehaviour("softRemovable", {
removed: 'deleted',
removedAt: 'deletedAt',
removedBy: 'removedBy',
restoredAt: 'restoredAt',
restoredBy: 'restoredBy'
});
if(Meteor.isServer) Meteor.publish('venues', function() {
return Venues.find({});
});
// //Requires: meteor add matb33:collection-hooks
if(Meteor.isServer) {
Venues.before.insert(function(userId, doc) {
// check(userId, String);
doc.createdAt = new Date();
});
Venues.before.update(function(userId, doc, fieldNames, modifier, options) {
modifier.$set = modifier.$set || {}; //Make sure there is an object.
modifier.$set.updatedAt = new Date();
});
Meteor.methods({
insertVenue: function(venue) {
check(venue, {
name: String,
type: String
});
venue.createdAt = new Date();
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
Venues.insert(venue);
}
else throw new Meteor.Error(403, "Not authorized.");
},
deleteVenue: function(id) {
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
Venues.remove(id);
}
else throw new Meteor.Error(403, "Not authorized.");
},
updateVenue: function(venue) {
check(venue, {
name: String,
type: String
});
if(Roles.userIsInRole(this.userId, [Meteor.UserRoles.ROLE_UPDATE])) {
Venues.update(id, {$set: {name: venue.name, type: venue.type, updateAt: new Date()}});
}
else throw new Meteor.Error(403, "Not authorized.");
}
});
}
//Allows the client to do DB interaction without calling server side methods, while still retaining control over whether the user can make changes.
// Meteor.allow({
// insert: true,
// update: ()->{return true},
// remove: checkUser
// };
// checkUser = function(userId, doc) {
// return doc && doc.userId === userId;
// };
//Allows the client to interact with the db through the server via custom methods.
// Meteor.methods({
// deleteMeasure: function(id) {
// Measures.remove(id);
// }
// });
export default Venues;

26
imports/api/index.js Normal file
View File

@@ -0,0 +1,26 @@
//import Categories from "./Category.js";
//import Subcategories from "./Subcategory.js";
import Measures from "./Measure.js";
import Venues from "./Venue.js";
import Products from "./Product.js";
import ProductTags from "./ProductTag.js";
import Sales from "./Sale.js";
import Users from "./User.js";
import UserRoles from "./Roles.js";
Meteor.collections = {Measures, Venues, Products, ProductTags, Sales, Users, UserRoles};
if(Meteor.isServer) {
//Change this to find admin users, create a default admin user if none exists.
if(Users.find({}).count() == 0) {
try {
console.log("Creating a default admin user: admin/admin");
let id = Accounts.createUser({password: 'admin', username: 'admin'});
//Requires the alanning:roles package.
Roles.addUsersToRoles(id, [Meteor.UserRoles.ROLE_MANAGE, Meteor.UserRoles.ROLE_UPDATE]);
}
catch(err) {
console.log(err);
}
}
}

View File

@@ -0,0 +1,68 @@
import { AccountsTemplates } from 'meteor/useraccounts:core';
AccountsTemplates.configure({
forbidClientAccountCreation: true,
showForgotPasswordLink: true,
defaultTemplate: 'atForm',
defaultLayout: 'Full',
defaultContentRegion: 'content',
defaultLayoutRegions: {}
// defaultTemplate: 'Auth_page',
// defaultLayout: 'Body',
// defaultContentRegion: 'content',
// defaultLayoutRegions: {}
});
// This removes the password field but returns it,
// so that you can re-add it later, preserving the
// desired order of the fields
// let pwd = AccountsTemplates.removeField('password');
// AccountsTemplates.removeField('email');
// AccountsTemplates.addFields([
// {
// _id: "username",
// type: "text",
// displayName: "username",
// required: true,
// minLength: 5,
// },
// pwd
// ]);
let pwd = AccountsTemplates.removeField('password');
AccountsTemplates.removeField('email');
AccountsTemplates.addFields([
{
_id: "username",
type: "text",
displayName: "username",
required: true,
minLength: 5,
},
{
_id: 'email',
type: 'email',
required: true,
displayName: "email",
re: /.+@(.+){2,}\.(.+){2,}/,
errStr: 'Invalid email',
},
pwd
]);
AccountsTemplates.configureRoute('signIn', {
name: 'signin',
path: '/signin'
});
// AccountsTemplates.configureRoute('signUp', {
// name: 'join',
// path: '/join'
// });
AccountsTemplates.configureRoute('forgotPwd');
AccountsTemplates.configureRoute('resetPwd', {
name: 'resetPwd',
path: '/reset-password',
});

View File

@@ -0,0 +1 @@
import './accounts.js';

View File

@@ -0,0 +1 @@
import './routes.js';

View File

@@ -0,0 +1,80 @@
//Look in /libs/startup/both/useraccounts.js for the login/logout/signup routing.
let pri = FlowRouter.group({
triggersEnter: [AccountsTemplates.ensureSignedIn]
});
pri.route('/', {
triggersEnter: [function(context, redirect) {redirect("/sales");}]
});
pri.route('/menu', {
name: 'Menu',
action: function(params, queryParams) {
require("/imports/ui/Menu.js");
BlazeLayout.render('Body', {content: 'Menu'});
}
});
pri.route('/admin', {
name: 'UserManager',
action: function(params, queryParams) {
require("/imports/ui/UserManager.js");
BlazeLayout.render('Body', {content: 'UserManager'});
}
});
pri.route('/measures', {
name: 'Measures',
action: function(params, queryParams) {
require("/imports/ui/Measures.js");
BlazeLayout.render('Body', {content: 'Measures'});
}
});
pri.route('/products', {
name: 'Products',
action: function(params, queryParams) {
require("/imports/ui/Products.js");
BlazeLayout.render('Body', {content: 'Products'});
}
});
pri.route('/productTags', {
name: 'ProductTags',
action: function(params, queryParams) {
require("/imports/ui/ProductTags.js");
BlazeLayout.render('Body', {content: 'ProductTags'});
}
});
// pri.route('/subcategories', {
// name: 'Items',
// action: function(params, queryParams) {
// require("/imports/ui/Subcategories.js");
// BlazeLayout.render('Body', {content: 'Subcategories'});
// }
// });
pri.route('/userManagement', {
name: 'UserManagement',
action: function(params, queryParams) {
require("/imports/ui/UserManagement.js");
BlazeLayout.render('Body', {content: 'UserManagement'});
}
});
pri.route('/sales', {
name: 'Sales',
action: function(params, queryParams) {
require("/imports/ui/Sales.js");
BlazeLayout.render('Body', {content: 'Sales'});
}
});
pri.route('/production', {
name: 'Production',
action: function(params, queryParams) {
require("/imports/ui/Production.js");
BlazeLayout.render('Body', {content: 'Production'});
}
});
pri.route('/pricing', {
name: 'Pricing',
action: function(params, queryParams) {
require("/imports/ui/Pricing.js");
BlazeLayout.render('Body', {content: 'Pricing'});
}
});

View File

@@ -0,0 +1,12 @@
Accounts.emailTemplates.from = "Do Not Reply <administrator@declarativeengineering.com>";
Accounts.emailTemplates.siteName = "Petit Teton App";
// Accounts.emailTemplates.verifyEmail.subject = function (user) {
// return "Welcome to My Site! Please verify your email";
// };
//
// Accounts.emailTemplates.verifyEmail.html = function (user, url) {
// return "Hi " + user.profile.firstName + " " + user.profile.lastName + ",\n\n" +
// " Please verify your email by simply clicking the link below:\n\n" +
// url;
// };

View File

@@ -0,0 +1 @@
import "./email.js"

View File

@@ -0,0 +1,33 @@
let AppVersion = new Mongo.Collection('AppVersion');
let AppVersionSchema = new SimpleSchema({
version: {
type: Number,
optional: false,
defaultValue: 0
}
});
AppVersion.attachSchema(AppVersionSchema);
try {
let appVersions = AppVersion.find({}).fetch();
let appVersion;
if(!appVersions || appVersions.length == 0) { //This will happen only when first creating a database.
appVersion = {version: 0};
appVersion._id = AppVersion.insert(appVersion);
}
else if(appVersions.length > 1) { //This should never happen. Remove all but the first app version.
for(let i = 1; i < appVersions.length; i++) {
AppVersion.remove(appVersions[i]._id);
}
}
else {
appVersion = appVersions[0];
}
}
catch(err) {
console.log("Caught an error while upgrading the app version: " + err);
process.exit(1);
}

3
imports/ui/Intro.html Normal file
View File

@@ -0,0 +1,3 @@
<template name="Intro">
<div id="intro">Intro</div>
</template>

4
imports/ui/Intro.import.styl vendored Normal file
View File

@@ -0,0 +1,4 @@
#intro
text-align: center
font-size: 4em
font-family: sans-serif

2
imports/ui/Intro.js Normal file
View File

@@ -0,0 +1,2 @@
import { Template } from 'meteor/templating';
import './Intro.html';

18
imports/ui/Measures.html Normal file
View File

@@ -0,0 +1,18 @@
<template name="Measures">
<table>
<thead>
<tr><td>Name</td></tr>
</thead>
<tbody>
{{#each measures}}
{{> MeasureRow}}
{{/each}}
</tbody>
</table>
</template>
<template name="MeasureRow">
<tr>
<td>{{name}}</td>
</tr>
</template>

0
imports/ui/Measures.import.styl vendored Normal file
View File

25
imports/ui/Measures.js Normal file
View File

@@ -0,0 +1,25 @@
import './Measures.html';
Template.Measures.helpers({
// someFunctionNameCalledByTemplate: function() {
// return something;
// }
measures: function () {
return Measures.find({});
}
});
Template.Measures.events({
// 'click .something': function() {
// Meteor.call('someMethodOnServer', this.something, someotherparam);
// Session.set('someValue', Session.get('someOtherValue'));
// console.log("Got here");
// }
'click .trash': function() {
//Calls deleteMeasure which is in the collection for Measures.
Meteor.call('deleteMeasure', this._id);
console.log("Got here");
}
});

20
imports/ui/Menu.html Normal file
View File

@@ -0,0 +1,20 @@
<template name="Menu">
<div id="menu">
<a class="option" href="/sales">
<i class="fa fa-usd"></i>
<p>Sales</p>
</a>
<a class="option" href="/prices">
<i class="fa fa-usd"></i>
<p>Prices</p>
</a>
<a class="option" href="/items">
<i class="fa fa-sitemap"></i>
<p>Items</p>
</a>
<a class="option" href="/configMenu">
<i class="fa fa-cog"></i>
<p>Settings</p>
</a>
</div>
</template>

56
imports/ui/Menu.import.styl vendored Normal file
View File

@@ -0,0 +1,56 @@
#menu {
flex: 0 0 100%;
display: -webkit-box;
display: -moz-box;
display: -ms-flexbox;
display: -moz-flex;
display: -webkit-flex;
display: flex;
flex-flow: row wrap;
justify-content: center; //Spacing between items along the primary axis. (vertical spacing for a column layout)
align-items: flex-start; //Align the items within a line along the primary axis. (horizontal alignment for a column layout)
align-content: flex-start; //Spacing between lines along the secondary axis. (spacing between columns for a column layout)
width: 100%;
.option {
height: 120px;
width: 120px;
background: grey;
margin: 20px;
overflow: hidden;
color: white;
//Flex element options.
flex: 0 0 120px; //Grow, Shrink, Basis
//Flex container options.
flex-flow: column nowrap;
justify-content: space-around; //Spacing between items along the primary axis. (vertical spacing for a column layout)
align-items: center; //Align the items within a line along the primary axis. (horizontal alignment for a column layout)
align-content: center; //Spacing between lines along the secondary axis. (spacing between columns for a column layout)
display: -webkit-box;
display: -moz-box;
display: -ms-flexbox;
display: -moz-flex;
display: -webkit-flex;
display: flex;
text-decoration: none;
i {
flex: 0 0;
font-size: 8em;
}
p {
flex: 0 0;
font-size: 1.5em;
text-align: center;
margin: 0;
}
}
.option:hover {
-moz-box-shadow: inset 0 0 20px #7a5a7a;
-webkit-box-shadow: inset 0 0 20px #7a5a7a;
box-shadow: inset 0 0 20px #7a5a7a;
}
.option:active {
background: #CCC;
}
}

2
imports/ui/Menu.js Normal file
View File

@@ -0,0 +1,2 @@
import { Template } from 'meteor/templating';
import './Menu.html';

54
imports/ui/Pricing.html Normal file
View File

@@ -0,0 +1,54 @@
<template name="Pricing">
<div id="pricing">
<div class="controls">
<div class="controlGroup floatRight">
<label class='controlLabel'>New Price: </label>
<input type="number" class="price" name="price" min="0" data-schema-key='currency' value="{{price}}" required>
<input type="button" class="btn btn-success applyButton" value="Apply">
<!--<span class="toggleUpdateHistory toggleButton clickable">Set Prev</span>-->
<div class="controlGroup outline">
<span class="controlLabel">Set Previous:</span>
<div class="toggleUpdateHistory checkbox checkbox-slider--b-flat">
<label>
<input type="checkbox" name="setPrevious" checked><span></span>
</label>
</div>
<label class='controlLabel'>Effective: </label>
<input type="date" class="form-control" name="date" data-schema-key='date' required>
</div>
</div>
<div class="controlGroup outline floatLeft" style="position: relative; top: 12px">
<label class='controlLabel'>Selected Measure: </label>
<select name="measures">
{{#each measures}}
<option value="{{_id}}">{{name}}</option>
{{/each}}
</select>
</div>
</div>
<table>
<thead>
<tr>
<th class="name">Name</th>
<th class="current">Current</th>
<th class="changeDate">Change Date</th>
<th class="previous">Previous</th>
</tr>
</thead>
<tbody>
{{#each product}}
{{> PricingForProduct}}
{{/each}}
</tbody>
</table>
</div>
</template>
<template name="PricingForProduct">
<tr class="clickable noselect">
<td>{{name}}</td>
<td>{{currentPrice}}</td>
<td>{{priceChangeDate}}</td>
<td>{{previousPrice}}</td>
</tr>
</template>

95
imports/ui/Pricing.import.styl vendored Normal file
View File

@@ -0,0 +1,95 @@
#pricing
margin: 10px 20px
height: 100%
.controls
text-align: left
.controlGroup
padding: 4px 8px
margin: 4px 8px
display: inline-block
.outline
border: 2px dotted #32747e
border-radius: 10px
.floatLeft
float: left
.floatRight
float: right
.controlLabel
font-size: 1.5em
font-weight: 700
select[name="measures"]
padding: 4px 8px
font-size: 1.5em
input
padding: 4px 8px
font-size: 1.5em
input[type="number"]
width: 80px
input[type="button"]
margin-top: -6px
margin-right: 20px
//.toggleButton
// padding: 6px 8px
// border: 1px solid #4cae4c
// border-radius: 4px
// font-size: 1.5em
// color: white
// background: #5b5
// font-family: inherit
//.toggleButton.inactive
// background: #FF6F77
// color: 888
.toggleUpdateHistory
margin: 0
position: relative
top: -4px
display: inline-block
//.inactive
// background: #666
input[type="date"]
width: 180px
display: inline-block
table
width: 100%
margin-bottom: 20px
border: 0
table-layout: fixed
font-size: 1.3em
thead
font-weight: 800
tr > th
background: #333
color: white
tr > th.name
width: auto
tr > th.current
width: 200px
tr > th.previous
width: 200px
tr > th.changeDate
width: 200px
tbody
text-align: left
tr:nth-child(even)
background: #DDD
.rowGroupHead
color: white
background: #333
tr.selected
//background: yellow
background-attachment: fixed
background-repeat: no-repeat
background-position: 0 0
background-image: linear-gradient(to left, #FCF8D1 70%,#f1da36 100%)
tr:nth-child(even).selected
background-attachment: fixed
background-repeat: no-repeat
background-position: 0 0
background-image: linear-gradient(to left, #E0DCBA 70%,#f1da36 100%)

114
imports/ui/Pricing.js Normal file
View File

@@ -0,0 +1,114 @@
import './Pricing.html';
Tracker.autorun(function() {
Meteor.subscribe("products");
Meteor.subscribe("measures");
});
Template.Pricing.onRendered(function() {
this.$('input[name="date"]').val(new Date().toDateInputValue());
});
Template.Pricing.helpers({
measures: function() {
//return Meteor.collections.Measures.find({}, {sort: {order: 1}});
let measures = Meteor.collections.Measures.find({}, {sort: {order: 1}}).fetch();
for(let i = 0; i < measures; i++) {
if(Meteor.collections.Products.find({measures: {$all: [measures[i]._id]}}, {sort: {name: 1}}).count() == 0)
measures.splice(i, 1); //Remove the measure from the list.
}
return measures;
},
product: function() {
let measureId = Session.get("selectedMeasure");
return Meteor.collections.Products.find({measures: {$all: [measureId]}}, {sort: {name: 1}});
}
});
Template.Pricing.events({
'change select[name="measures"]': function(event, template) {
Session.set("selectedMeasure", $(event.target).val());
},
'click .applyButton': function(event, template) {
let measureId = Session.get("selectedMeasure");
let $selectedRows = template.$('tr.selected');
// let selectedProducts = $selectedRows.map(function() {return $(this).data('product')});
let price = Number(template.$('input[name="price"]').val());
let setPrevious = template.$('input[name="setPrevious"]').prop('checked');
let date = template.$('input[name="date"]').val();
date = moment(date ? date : new Date().toDateInputValue(), "YYYY-MM-DD").toDate();
setPrevious = setPrevious == true || setPrevious == 'on' || setPrevious == "true" || setPrevious == "yes";
if(setPrevious == true && !date) {
sAlert.error("Unexpected input.");
}
if(!price || isNaN(price) || price < 0) {
sAlert.error("Unexpected input.");
}
for(let i = 0; i < $selectedRows.length; i++) {
let product = $($selectedRows[i]).data('product');
Meteor.call("setProductPrice", product._id, measureId, price, setPrevious, date);
}
}
});
// Template.PricingForProduct.onCreated(function() {
//
// });
Template.PricingForProduct.onRendered(function() {
this.$('tr').data("product", this.data);
});
Template.PricingForProduct.helpers({
currentPrice: function() {
let measureId = Session.get("selectedMeasure");
let price = this.prices && measureId && this.prices[measureId] && this.prices[measureId].price ? this.prices[measureId].price : undefined;
return price ? price.toLocaleString("en-US", {style: 'currency', currency: 'USD', minimumFractionDigits: 2}) : "-";
},
previousPrice: function() {
let measureId = Session.get("selectedMeasure");
let price = this.prices && measureId && this.prices[measureId] && this.prices[measureId].previousPrice ? this.prices[measureId].previousPrice : undefined;
return price ? price.toLocaleString("en-US", {style: 'currency', currency: 'USD', minimumFractionDigits: 2}) : "-";
},
priceChangeDate: function() {
let measureId = Session.get("selectedMeasure");
let date = this.prices && measureId && this.prices[measureId] && this.prices[measureId].effectiveDate ? this.prices[measureId].effectiveDate : undefined;
return date ? moment(date).format("MM/DD/YYYY (w)") : "-";
}
});
Template.PricingForProduct.events({
'click tr': function(event, template) {
let $row = template.$(event.target).closest("tr");
let parentTemplate = template.parentTemplate(1);
if(event.shiftKey) {
let $lastRow = parentTemplate.$lastClickedRow;
let $range = ($row.index() > $lastRow.index() ? $lastRow.nextUntil($row) : $row.nextUntil($lastRow)).add($row);
if(event.ctrlKey) {
$range.toggleClass("selected");
}
else {
$range.addClass("selected");
}
}
else if(event.ctrlKey) {
$row.toggleClass("selected");
}
else {
$row.addClass("selected");
$row.siblings().removeClass('selected');
}
//Store the last row clicked on in a non-reactive variable attached to the parent template.
parentTemplate.$lastClickedRow = $row;
}
});

View File

@@ -0,0 +1,66 @@
<template name="ProductTags">
<div id="productTags">
{{#if Template.subscriptionsReady}}
<div class="insert">
{{>ProductTagInsert}}
</div>
<div class="grid">
<table class="dataTable table table-striped table-hover">
<thead>
<tr class="headers">
<th class="tdLarge noselect nonclickable" style="max-width: 300px">Name</th>
<th class="tdLarge noselect nonclickable" style="width: 90px">Actions</th>
</tr>
<tr class="footers">
<th>{{>ProductTagSearch columnName='name'}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{#each productTags}}
{{> ProductTag}}
{{/each}}
</tbody>
</table>
</div>
{{else}}
{{/if}}
</div>
</template>
<template name="ProductTag">
<tr>
{{#if editing}}
<td><input name="name" class="form-control" type="text" value="{{name}}" required></td>
<td class="center tdLarge"><i class="editorCancel fa fa-times-circle fa-lg noselect clickable" aria-hidden="true"></i>&nbsp;/&nbsp;<i class="editorApply fa fa-check-square-o fa-lg noselect clickable" aria-hidden="true"></i></td>
{{else}}
<td class="tdLarge noselect nonclickable">{{name}}</td>
<td class="center tdLarge"><i class="tagRemove fa fa-times-circle fa-lg noselect clickable" aria-hidden="true"></i>&nbsp;/&nbsp;<i class="tagEdit fa fa-pencil-square-o fa-lg noselect clickable" aria-hidden="true"></i></td>
{{/if}}
</tr>
</template>
<template name="ProductTagSearch">
<div class="">
<input type="text" class="searchInput" placeholder="Filter..." value="{{searchValue}}"/>
</div>
</template>
<template name="ProductTagInsert">
<form name="insert" autocomplete="off">
<div class="row">
<div class="col-md-3 col-sm-0"></div>
<div class="col-md-6 col-sm-12">
<div class="formGroupHeading">New Product Tag</div>
<div class="form-group">
<label class='control-label'>Name</label>
<input name="username" type="text" class="form-control" required>
</div>
<div class="form-group">
<input type="submit" class="btn btn-success" value="Create">
</div>
</div>
<div class="col-md-3 col-sm-0"></div>
</div>
</form>
</template>

88
imports/ui/ProductTags.import.styl vendored Normal file
View File

@@ -0,0 +1,88 @@
#productTags
margin: 20px 20px
height: 100%
//Flex container options.
flex-flow: column nowrap
justify-content: space-around //Spacing between sales along the primary axis. (vertical spacing for a column layout)
align-items: flex-start //Align the sales within a line along the primary axis. (horizontal alignment for a column layout)
align-content: center //Spacing between lines along the secondary axis. (spacing between columns for a column layout)
display: -webkit-box
display: -moz-box
display: -ms-flexbox
display: -moz-flex
display: -webkit-flex
display: flex
text-align: left
.editor
height: 100%
overflow-y: auto
.insert
flex: none
width: 100%
.col-md-6
padding: 10px 30px 0 30px
background: #EFEFEF
border-radius: 1em
.formGroupHeading
font-size: 1.6em
font-family: "Arial Black", "Arial Bold", Gadget, sans-serif
font-style: normal
font-variant: normal
font-weight: 500
.grid
flex: auto
align-self: stretch
overflow-y: auto
overflow-x: auto
margin-bottom: 20px
border: 0
padding-top: 20px
.table > thead > tr > th
border: 0
padding-top: 0
padding-bottom: 6px
.dataTable
table-layout: fixed
width: auto
.tdLarge
font-size: 1.5em
.tagRemove
color: red
.tagEdit
color: darkblue
.editorApply
color: green
.editorCancel
color: red
td.roles
.role
padding: 4px 4px
border: 1px solid #555
border-radius: .25em
background: white
color: #999
cursor: pointer
.selected
color: black
div.roles
padding: 4px 0
.role
padding: 4px 4px
border: 1px solid #555
border-radius: .25em
background: white
color: #999
cursor: pointer
.selected
color: black
.center
vertical-align: middle !important

132
imports/ui/ProductTags.js Normal file
View File

@@ -0,0 +1,132 @@
import './ProductTags.html';
Tracker.autorun(function() {
Meteor.subscribe("productTags");
});
Template.ProductTags.helpers({
productTags: function() {
return Meteor.collections.ProductTags.find(Session.get('searchQuery') || {}, {sort: {name: -1}});
}
});
Template.ProductTag.onCreated(function() {
this.edited = new ReactiveVar();
});
Template.ProductTag.events({
"click .tagEdit": function(event, template) {
template.edited.set(this);
},
"click .tagRemove": function(event, template) {
let _this = this;
bootbox.confirm({
message: "Delete the product tag?",
buttons: {confirm: {label: "Yes", className: 'btn-success'}, cancel: {label: "No", className: "btn-danger"}},
callback: function(result) {
if(result) {
Meteor.call('deleteProductTag', _this._id, function(error, result) {
if(error) {
sAlert.error(error);
}
else {
sAlert.success("Product tag removed.");
}
});
}
}
});
},
"click .editorCancel": function(event, template) {
template.edited.set(undefined);
},
"click .editorApply": function(event, template) {
let name = template.$("input[name='name']").val().trim();
//Basic validation.
if(name) {
Meteor.call("updateProductTag", {_id: this._id, name: name}, function(error, result) {
if(error) {
sAlert.error(error);
}
else {
sAlert.success("Product tag updated.");
}
});
}
template.edited.set(undefined);
},
"click .role": function(event, template) {
$(event.target).toggleClass("selected");
}
});
Template.ProductTag.helpers({
editing: function() {
return Template.instance().edited.get() == this;
}
});
Template.ProductTagSearch.events({
"keyup .searchInput": _.throttle(function(event, template) {
let searchQuery = Session.get('searchQuery') || {};
let searchFields = Session.get('searchFields') || {};
let searchValue = template.$('.searchInput').val();
if(searchValue) {
if(this.number) searchValue = parseFloat(searchValue);
if(this.collection) {
let ids = Meteor.collections[this.collection].find({[this.collectionQueryColumnName]: {$regex: searchValue, $options: 'i'}}, {fields: {[this.collectionResultColumnName]: 1}}).fetch();
//Convert the ids to an array of ids instead of an array of objects containing an id.
for(let i = 0; i < ids.length; i++) {ids[i] = ids[i]._id;}
searchQuery[this.columnName] = {$in: ids};
searchFields[this.columnName] = searchValue;
}
else {
searchFields[this.columnName] = searchQuery[this.columnName] = searchValue;
}
}
else {
//Remove columns from the search query whose values are empty so we don't bother the database with them.
delete searchQuery[this.columnName];
delete searchFields[this.columnName];
}
Session.set('searchQuery', searchQuery);
}, 500)
});
Template.ProductTagSearch.helpers({
searchValue: function() {
let searchFields = Session.get('searchFields');
return (searchFields && searchFields[this.columnName]) ? searchFields[this.columnName] : '';
}
});
Template.ProductTagInsert.onRendered(function() {
this.$('form[name="insert"]').validator();
});
Template.ProductTagInsert.events({
'click input[type="submit"]': function(event, template) {
event.preventDefault();
template.$('form[name="insert"]').data('bs.validator').validate(function(isValid) {
if(isValid) {
let name = template.$('input[name="name"]').val();
Meteor.call('insertProductTag', name, function(error, result) {
if(error) {
sAlert.error(error);
}
else {
sAlert.success("Product tag created.");
}
});
}
});
},
"click .role": function(event, template) {
$(event.target).toggleClass("selected");
}
});

View File

@@ -0,0 +1,5 @@
<template name="Production">
<div id="production">
todo
</div>
</template>

60
imports/ui/Production.import.styl vendored Normal file
View File

@@ -0,0 +1,60 @@
#production
margin: 10px 20px
height: 100%
//Flex container options.
flex-flow: column nowrap
justify-content: space-around //Spacing between sales along the primary axis. (vertical spacing for a column layout)
align-items: flex-start //Align the sales within a line along the primary axis. (horizontal alignment for a column layout)
align-content: center //Spacing between lines along the secondary axis. (spacing between columns for a column layout)
display: -webkit-box
display: -moz-box
display: -ms-flexbox
display: -moz-flex
display: -webkit-flex
display: flex
.editor
height: 100%
overflow-y: auto
.insertSale
flex: none
width: 100%
.formGroupHeading
font-size: 1.6em
font-family: "Arial Black", "Arial Bold", Gadget, sans-serif
font-style: normal
font-variant: normal
font-weight: 500
.grid
flex: auto
align-self: stretch
overflow-y: auto
overflow-x: auto
margin-bottom: 20px
border: 0
padding-top: 20px
.table > thead > tr > th
border: 0
padding-top: 0
padding-bottom: 6px
.left
text-align: left
.center
text-align: center
.dataTable
table-layout: fixed
.tdLarge
font-size: 1.3em
.saleRemove
color: red
margin-left: 8px
.saleEdit
color: darkblue
margin-right: 8px

2
imports/ui/Production.js Normal file
View File

@@ -0,0 +1,2 @@
import './Production.html';

38
imports/ui/Products.html Normal file
View File

@@ -0,0 +1,38 @@
<template name="Products">
<div id="products">
<div class="grid">
<div class="dataTable">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Name {{>ProductSearch columnName='name'}}</th>
<th>Tags {{>ProductSearch columnName='tags'}}</th>
<th>Aliases {{>ProductSearch columnName='aliases'}}</th>
<th>Measures {{>ProductSearch columnName='measures' collectionQueryColumnName='name' collection='Measures' collectionResultColumnName='_id'}}</th>
</tr>
</thead>
<tbody>
{{#each products}}
{{> Product}}
{{/each}}
</tbody>
</table>
</div>
</div>
</div>
</template>
<template name="Product">
<tr>
<td class="tdLarge noselect nonclickable left">{{name}}</td>
<td class="tdLarge noselect nonclickable left">{{tags}}</td>
<td class="tdLarge noselect nonclickable left">{{aliases}}</td>
<td class="tdLarge noselect nonclickable left">{{measures}}</td>
</tr>
</template>
<template name="ProductSearch">
<div class="">
<input type="text" class="searchInput" placeholder="Filter..." value="{{searchValue}}"/>
</div>
</template>

52
imports/ui/Products.import.styl vendored Normal file
View File

@@ -0,0 +1,52 @@
#products
height: 100%;
.editor
height: 100%;
overflow-y: auto;
.grid
height: 100%;
//Flex container options.
flex-flow: column nowrap;
justify-content: space-around; //Spacing between items along the primary axis. (vertical spacing for a column layout)
align-items: flex-start; //Align the items within a line along the primary axis. (horizontal alignment for a column layout)
align-content: center; //Spacing between lines along the secondary axis. (spacing between columns for a column layout)
display: -webkit-box;
display: -moz-box;
display: -ms-flexbox;
display: -moz-flex;
display: -webkit-flex;
display: flex;
.buttonContainer
//Flex element options.
//flex: 0 0; //Grow, Shrink, Basis
flex: none;
.dataTable
overflow-y: auto;
//Flex element options.
flex: auto;
align-self: stretch;
height: 10%;
max-height: 100%;
.padding
flex: none;
height: 1px;
width: 100%;
#DFAliases
width: 100%;
height: 150px;
overflow: auto;
span
font-family: Arial, Helvetica, sans-serif;
font-size: 1.5em;
cursor: pointer;
display: block;
span.selected
background-color: rgba(255, 248, 131, 0.51);

103
imports/ui/Products.js Normal file
View File

@@ -0,0 +1,103 @@
import './Products.html';
Tracker.autorun(function() {
Meteor.subscribe("products");
Meteor.subscribe("productTags");
});
Template.Products.helpers({
products: function() {
let query = Session.get('searchQuery');
let dbQuery = {};
if(query) {
_.each(_.keys(query), function(key) {
if(_.isFunction(query[key])) dbQuery[key] = query[key]();
else if(_.isObject(query[key])) dbQuery[key] = query[key];
else if(_.isNumber(query[key])) dbQuery[key] = query[key];
else dbQuery[key] = {$regex: query[key], $options: 'i'};
})
}
return Meteor.collections.Products.find(dbQuery, {limit: 20, sort: {name: 1}});
}
});
Template.ProductSearch.events({
"keyup .searchInput": _.throttle(function(event, template) {
let searchQuery = Session.get('searchQuery') || {};
let searchFields = Session.get('searchFields') || {};
let searchValue = template.$('.searchInput').val();
if(searchValue) {
if(this.number) searchValue = parseFloat(searchValue);
if(this.collection) {
let ids = Meteor.collections[this.collection].find({[this.collectionQueryColumnName]: {$regex: searchValue, $options: 'i'}}, {fields: {[this.collectionResultColumnName]: 1}}).fetch();
//Convert the ids to an array of ids instead of an array of objects containing an id.
for(let i = 0; i < ids.length; i++) {ids[i] = ids[i]._id;}
searchQuery[this.columnName] = {$in: ids};
searchFields[this.columnName] = searchValue;
}
else {
searchFields[this.columnName] = searchQuery[this.columnName] = searchValue;
}
}
else {
//Remove columns from the search query whose values are empty so we don't bother the database with them.
delete searchQuery[this.columnName];
delete searchFields[this.columnName];
}
Session.set('searchQuery', searchQuery);
}, 500)
});
Template.ProductSearch.helpers({
searchValue: function() {
let searchFields = Session.get('searchFields');
return (searchFields && searchFields[this.columnName]) ? searchFields[this.columnName] : '';
}
});
Template.Product.helpers({
measures: function() {
let result = "";
if(this.measures && this.measures.length > 0) {
let measureNames = [];
for(let i = 0; i < this.measures.length; i++) {
let measureObject = Meteor.collections.Measures.findOne(this.measures[i]);
if(measureObject && measureObject.name)
measureNames.push(measureObject.name);
}
result = measureNames.join(", ");
}
return result;
},
tags: function() {
let result = "";
if(this.tags && this.tags.length > 0) {
let tagNames = [];
for(let i = 0; i < this.tags.length; i++) {
let obj = Meteor.collections.ProductTags.findOne(this.tags[i]);
if(obj && obj.name)
tagNames.push(obj.name);
}
result = tagNames.join(", ");
}
return result;
}
});

129
imports/ui/Sales.html Normal file
View File

@@ -0,0 +1,129 @@
<template name="Sales">
<div id="salesMain">
{{#if Template.subscriptionsReady}}
<div class="insertSale">
{{>InsertSale}}
</div>
<div class="grid">
<table class="dataTable table table-striped table-hover">
<thead>
<tr class="headers">
<th class="tdLarge noselect nonclickable" style="width: 80px">Amount</th>
<th class="tdLarge noselect nonclickable">Product</th>
<th class="tdLarge noselect nonclickable" style="width: 140px">Price</th>
<th class="tdLarge noselect nonclickable" style="width: 90px">Measure</th>
<th class="tdLarge noselect nonclickable" style="width: 140px">Date (Week)</th>
<th class="tdLarge noselect nonclickable" style="width: 120px">Venue</th>
<th class="tdLarge noselect nonclickable" style="width: 90px">Actions</th>
</tr>
<tr class="footers">
<th>{{>SaleSearch columnName='amount' width='90%'}}</th>
<th>{{>SaleSearch columnName='productId' collectionQueryColumnName='name' collection='Products' collectionResultColumnName='_id' width='90%'}}</th>
<th>{{>SaleSearch columnName='price' width='90%'}}</th>
<th>{{>SaleSearch columnName='measureId' collectionQueryColumnName='name' collection='Measures' collectionResultColumnName='_id' width='90%'}}</th>
<th></th>
<th>{{>SaleSearch columnName='venueId' collectionQueryColumnName='name' collection='Venues' collectionResultColumnName='_id' width='90%'}}</th>
<th></th>
</tr>
</thead>
<tbody>
{{#each sales}}
{{> Sale}}
{{/each}}
</tbody>
</table>
</div>
{{else}}
{{/if}}
</div>
</template>
<template name="Sale">
<tr>
<!--{{#if editable}}-->
<td class="tdLarge noselect nonclickable center">{{amount}}</td>
<td class="tdLarge noselect nonclickable left">{{productName productId}}</td>
<td class="tdLarge noselect nonclickable left">{{formatPrice price}}{{#if showTotalPrice amount}} ({{formatTotalPrice price amount}}){{/if}}</td>
<td class="tdLarge noselect nonclickable left">{{measureName measureId}}</td>
<td class="tdLarge noselect nonclickable left">{{formatDate date}}</td>
<td class="tdLarge noselect nonclickable left">{{venueName venueId}}</td>
<td class="tdLarge noselect left"><i class="fa fa-times-circle fa-lg saleRemove clickable" aria-hidden="true"></i></td>
<!--<a class="saleEdit" href="javascript:"><i class="fa fa-pencil-square-o fa-lg" aria-hidden="true"></i></a>/-->
<!--{{else}}-->
<!--<form class="editSaleForm" autocomplete="off">-->
<!--<td><input name="amount" class="form-control" type="number" min="0" data-schema-key='amount' value="{{amount}}" required></td>-->
<!--<td><input name="product" class="form-control" type="text" required/></td>-->
<!--<td><input name="price" class="form-control" type="number" min="0" data-schema-key='currency' value="{{price}}" required></td>-->
<!--<td>-->
<!--<select name="measure" class="form-control" required>-->
<!--{{#each measures}}-->
<!--<option value="{{this._id}}">{{this.name}}</option>-->
<!--{{/each}}-->
<!--</select>-->
<!--</td>-->
<!--<td><input type="date" class="form-control" name="date" data-schema-key='date' value="{{date}}" required></td>-->
<!--<td>-->
<!--<select name="venue" class="form-control" required>-->
<!--{{#each venues}}-->
<!--<option value="{{this._id}}">{{this.name}}</option>-->
<!--{{/each}}-->
<!--</select>-->
<!--</td>-->
<!--<td><a class="editorSave" href="javascript:"><i class="fa fa-check-square-o fa-lg" aria-hidden="true"></i></a>/<a class="editorCancel" href="javascript:"><i class="fa fa-times-circle fa-lg" aria-hidden="true"></i></a></td>-->
<!--</form>-->
<!--{{/if}}-->
</tr>
</template>
<template name="SaleSearch">
<input type="text" class="searchInput" placeholder="Filter..." value="{{searchValue}}" style="padding-right: 10px; width: {{width}}"/>
</template>
<template name="InsertSale">
<form id="insertSale" autocomplete="off">
<div class="row">
<div class="col-md-4 col-sm-6">
<div class="formGroupHeading">New Sale</div>
<div class="form-group">
<label for='InsertSaleDate' class='control-label'>Date</label>
<input type="date" class="form-control" name="date" data-schema-key='date' required>
</div>
<div class="form-group">
<label for='InsertSaleProduct' class='control-label'>Product</label>
<input name="product" class="form-control" type="text" required/>
</div>
<div class="form-group">
<label for='InsertSaleVenue' class='control-label'>Venue</label>
<input name="venue" class="form-control" type="text" required/>
</div>
</div>
{{#each productMeasures}}
{{>InsertSaleMeasure this}}
{{/each}}
<div class="col-md-12">
<div class="form-group">
<input type="submit" class="btn btn-success" value="Save Sale">
</div>
</div>
</div>
</form>
</template>
<template name="InsertSaleMeasure">
<div class="col-md-4 col-sm-6 insertSaleMeasure">
<div class="formGroupHeading">{{name}}</div>
<input type="hidden" class="measureId" value="{{this._id}}">
<div class="form-group">
<label class='control-label'>Amount</label>
<input type="number" class="form-control amount" name="amount" min="0" data-schema-key='amount' value="{{amount}}" required>
</div>
<div class="form-group">
<label class='control-label'>Price</label>
<input type="number" class="form-control price" name="price" min="0" data-schema-key='currency' value="{{price}}" required>
</div>
<div class="form-group">
<label class='control-label'>Total</label>
<input type="number" class="form-control total" name="total" tabindex="-1" data-schema-key='currency' value="{{total}}" readonly>
</div>
</div>
</template>

58
imports/ui/Sales.import.styl vendored Normal file
View File

@@ -0,0 +1,58 @@
#salesMain
margin: 10px 20px
height: 100%
//Flex container options.
flex-flow: column nowrap
justify-content: space-around //Spacing between sales along the primary axis. (vertical spacing for a column layout)
align-items: flex-start //Align the sales within a line along the primary axis. (horizontal alignment for a column layout)
align-content: center //Spacing between lines along the secondary axis. (spacing between columns for a column layout)
display: -webkit-box
display: -moz-box
display: -ms-flexbox
display: -moz-flex
display: -webkit-flex
display: flex
.editor
height: 100%
overflow-y: auto
.insertSale
flex: none
width: 100%
.form-group, label
text-align: left
.formGroupHeading
font-size: 1.6em
font-family: "Arial Black", "Arial Bold", Gadget, sans-serif
font-style: normal
font-variant: normal
font-weight: 500
.grid
flex: auto
align-self: stretch
overflow-y: auto
overflow-x: auto
margin-bottom: 20px
border: 0
padding-top: 20px
.table > thead > tr > th
border: 0
padding-top: 0
padding-bottom: 6px
.dataTable
table-layout: fixed
.tdLarge
font-size: 1.3em
.saleRemove
color: red
margin-left: 8px
.saleEdit
color: darkblue
margin-right: 8px

267
imports/ui/Sales.js Normal file
View File

@@ -0,0 +1,267 @@
import './Sales.html';
import '/imports/util/selectize/selectize.js'
import ResizeSensor from '/imports/util/resize/ResizeSensor.js';
Tracker.autorun(function() {
Meteor.subscribe("products");
Meteor.subscribe("sales", Session.get('searchQuery'));
});
Template.Sales.onRendered(function() {
// console.log("moving headers");
// try {
// //Move the headers into the header table that will maintain its position.
// //Link the column widths to the header widths.
// let newHeaderRow = this.$('.dataTableHeader thead tr:first');
// let newFooterRow = this.$('.dataTableFooter thead tr:first');
// let oldHeaders = this.$('.dataTable thead tr.headers th');
// let oldFooters = this.$('.dataTable thead tr.footers th');
//
// console.log("header count " + oldHeaders.length);
//
// for(let index = 0; index < oldHeaders.length; index++) {
// let width = this.$('.dataTable tbody tr:first td:eq(' + index + ')').width();
// let oldHeader = oldHeaders.eq(index);
// let newHeader = $("<th\>");
// oldHeader.replaceWith(newHeader);
// newHeader.width(width);
// oldHeader.appendTo(newHeaderRow);
// //Link the two headers so that the visible header changes size with the hidden one.
// //TODO: Turn this off if manually resizing the visible headers - while resizing.
// new ResizeSensor(newHeader, function() {
// oldHeader.width(newHeader.width());
// });
// }
//
// for(let index = 0; index < oldFooters.length; index++) {
// let width = this.$('.dataTable tbody tr:first td:eq(' + index + ')').width();
// let oldFooter = oldFooters.eq(index);
// let newFooter = $("<th\>");
// oldFooter.replaceWith(newFooter);
// newFooter.width(width);
// oldFooter.appendTo(newFooterRow);
// //Link the two headers so that the visible header changes size with the hidden one.
// //TODO: Turn this off if manually resizing the visible headers - while resizing.
// new ResizeSensor(newFooter, function() {
// oldFooter.width(newFooter.width());
// });
// }
// }
// catch(err) {
// console.log(err);
// }
});
Template.Sales.helpers({
sales: function() {
return Meteor.collections.Sales.find({}, {sort: {date: -1}});
}
});
Template.Sale.onCreated(function() {
});
Template.Sale.events({
"click .saleRemove": function(event, template) {
let _this = this;
bootbox.confirm({
message: "Delete the sale?",
buttons: {confirm: {label: "Yes", className: 'btn-success'}, cancel: {label: "No", className: "btn-danger"}},
callback: function(result) {
if(result) {
// Meteor.collections.Sales.remove(_this._id);
Meteor.call('deleteSale', _this._id);
}
}
});
}
});
Template.Sale.helpers({
measureName: function(id) {
return Meteor.collections.Measures.findOne({_id: id}, {fields: {name: 1}}).name;
},
venueName: function(id) {
return Meteor.collections.Venues.findOne({_id: id}, {fields: {name: 1}}).name;
},
productName: function(id) {
return Meteor.collections.Products.findOne({_id: id}, {fields: {name: 1}}).name;
},
formatDate: function(date) {
return moment(date).format("MM/DD/YYYY (w)");
},
formatPrice: function(price) {
return price.toLocaleString("en-US", {style: 'currency', currency: 'USD', minimumFractionDigits: 2});
},
formatTotalPrice: function(price, amount) {
return (price * amount).toLocaleString("en-US", {style: 'currency', currency: 'USD', minimumFractionDigits: 2});
},
showTotalPrice: function(amount) {
return amount > 1;
}
});
Template.SaleSearch.events({
"keyup .searchInput": _.throttle(function(event, template) {
let searchQuery = Session.get('searchQuery') || {};
let searchFields = Session.get('searchFields') || {};
let searchValue = template.$('.searchInput').val();
if(searchValue) {
if(this.number) searchValue = parseFloat(searchValue);
if(this.collection) {
let ids = Meteor.collections[this.collection].find({[this.collectionQueryColumnName]: {$regex: searchValue, $options: 'i'}}, {fields: {[this.collectionResultColumnName]: 1}}).fetch();
//Convert the ids to an array of ids instead of an array of objects containing an id.
for(let i = 0; i < ids.length; i++) {ids[i] = ids[i]._id;}
searchQuery[this.columnName] = {$in: ids};
searchFields[this.columnName] = searchValue;
}
else {
searchFields[this.columnName] = searchQuery[this.columnName] = searchValue;
}
}
else {
//Remove columns from the search query whose values are empty so we don't bother the database with them.
delete searchQuery[this.columnName];
delete searchFields[this.columnName];
}
Session.set('searchQuery', searchQuery);
}, 500)
});
Template.SaleSearch.helpers({
searchValue: function() {
let searchFields = Session.get('searchFields');
return (searchFields && searchFields[this.columnName]) ? searchFields[this.columnName] : '';
}
});
// let SelectedProduct = new ReactiveVar();
Template.InsertSale.onCreated(function() {
// $('#insertSale').validator();
// $('#insertSale').data('bs.validator');
// this.products = new ReactiveVar([]);
this.selectedProduct = new ReactiveVar();
this.selectedVenue = new ReactiveVar();
});
Template.InsertSale.onRendered(function() {
$('#insertSale').validator();
// this.$('[name="product"]').
// this.autorun(function() {
// this.$('[name="product"]').buildCombo(Meteor.collections.Products.find({}).fetch(), {textAttr: 'name', listClass: 'comboList'});
// });
this.$('[name="product"]').buildCombo({cursor: Meteor.collections.Products.find({}), selection: this.selectedProduct, textAttr: 'name', listClass: 'comboList'});
this.$('[name="venue"]').buildCombo({cursor: Meteor.collections.Venues.find({}), selection: this.selectedVenue, textAttr: 'name', listClass: 'comboList'});
// this.autorun(function(){
// this.products.set(Meteor.collections.Products.find({}));
// }.bind(this));
});
Template.InsertSale.events({
'change #InsertSaleProduct': function(event, template) {
let selectedId = $('#InsertSaleProduct').val();
let selected = Meteor.collections.Products.findOne(selectedId);
template.selectedProduct.set(selected);
},
'click input[type="submit"]': function(event, template) {
event.preventDefault();
$('#insertSale').data('bs.validator').validate(function(isValid) {
if(isValid) {
let sales = [];
let sale = {
date: moment(template.find("[name='date']").value, "YYYY-MM-DD").toDate(),
productId: template.selectedProduct.get()._id,
venueId: template.selectedVenue.get()._id
};
let insertSaleMeasures = template.$(".insertSaleMeasure");
for(let next = 0; next < insertSaleMeasures.length; next++) {
let nextMeasure = $(insertSaleMeasures[next]);
let measureId = nextMeasure.find(".measureId").val();
let price = parseFloat(nextMeasure.find(".price").val()).toFixed(2);
let amount = parseFloat(nextMeasure.find(".amount").val()).toFixed(2);
if(amount > 0) {
let nextSale = _.clone(sale);
nextSale.measureId = measureId;
nextSale.price = price;
nextSale.amount = amount;
sales.push(nextSale);
}
}
// let debug = "Inserting: ";
// for(next in sales) {
// debug += "\n\t" + next;
// }
// console.log(debug);
for(let index = 0; index < sales.length; index++) {
let next = sales[index];
console.log("Inserting: " + JSON.stringify(next));
// Meteor.collections.Sales.insert(next, function(err, id) {
// if(err) console.log(err);
// });
Meteor.call('insertSale', next);
}
}
});
}
});
Template.InsertSale.helpers({
// sales: function() {
// return Meteor.collections.Sales;
// },
products: function() {
//return Meteor.collections.Products.find({});
//return this.products;
return [{label: "Hermies", value: 1}, {label: "Ralfe", value: 2}, {label: "Bob", value: 3}];
},
productMeasures: function() {
let product = Template.instance().selectedProduct.get();
let result = product ? product.measures : [];
for(let i = 0; i < result.length; i++) {
result[i] = Meteor.collections.Measures.findOne(result[i]);
}
if(product) console.log("Found " + result.length + " measures for the product " + product.name);
else console.log("No product!");
return result;
},
venues: function() {
return Meteor.collections.Venues.find({});
}
});
Template.InsertSaleMeasure.onCreated(function() {
let prices = this.parentTemplate().selectedProduct.get().prices;
let price = 0;
if(prices) price = prices[this._id];
this.price = new ReactiveVar(price);
this.amount = new ReactiveVar(0);
});
Template.InsertSaleMeasure.events({
'change .price': function(event, template) {
template.price.set(parseFloat($(event.target).val()).toFixed(2));
},
'change .amount': function(event, template) {
template.amount.set(parseFloat($(event.target).val()).toFixed(2));
}
});
Template.InsertSaleMeasure.helpers({
price: function() {
return Template.instance().price.get();
},
total: function() {
let template = Template.instance();
return template.price.get() * template.amount.get();
},
amount: function() {
return Template.instance().amount.get();
}
});

View File

@@ -0,0 +1,34 @@
<template name="Subcategories">
<div id="subcategories">
<div class="grid">
<div class="dataTable">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Name {{>SubcategorySearch columnName='name'}}</th>
<th>Category {{>SubcategorySearch columnName='categoryId' collectionQueryColumnName='name' collection='Categories' collectionResultColumnName='_id'}}</th>
</tr>
</thead>
<tbody>
{{#each subcategories}}
{{> Subcategory}}
{{/each}}
</tbody>
</table>
</div>
</div>
</div>
</template>
<template name="Subcategory">
<tr>
<td>{{name}}</td>
<td>{{categoryName categoryId}}</td>
</tr>
</template>
<template name="SubcategorySearch">
<div class="">
<input type="text" class="searchInput" placeholder="Filter..." value="{{searchValue}}"/>
</div>
</template>

52
imports/ui/Subcategories.import.styl vendored Normal file
View File

@@ -0,0 +1,52 @@
#subcategories
height: 100%;
.editor
height: 100%;
overflow-y: auto;
.grid
height: 100%;
//Flex container options.
flex-flow: column nowrap;
justify-content: space-around; //Spacing between items along the primary axis. (vertical spacing for a column layout)
align-items: flex-start; //Align the items within a line along the primary axis. (horizontal alignment for a column layout)
align-content: center; //Spacing between lines along the secondary axis. (spacing between columns for a column layout)
display: -webkit-box;
display: -moz-box;
display: -ms-flexbox;
display: -moz-flex;
display: -webkit-flex;
display: flex;
.buttonContainer
//Flex element options.
//flex: 0 0; //Grow, Shrink, Basis
flex: none;
.dataTable
overflow-y: auto;
//Flex element options.
flex: auto;
align-self: stretch;
height: 10%;
max-height: 100%;
.padding
flex: none;
height: 1px;
width: 100%;
#DFAliases
width: 100%;
height: 150px;
overflow: auto;
span
font-family: Arial, Helvetica, sans-serif;
font-size: 1.5em;
cursor: pointer;
display: block;
span.selected
background-color: rgba(255, 248, 131, 0.51);

View File

@@ -0,0 +1,76 @@
import './Subcategories.html';
// Tracker.autorun(function() {
// Meteor.subscribe("subcategories");
// });
Template.Subcategories.helpers({
subcategories: function() {
let query = Session.get('searchQuery');
let dbQuery = {};
if(query) {
_.each(_.keys(query), function(key) {
if(_.isFunction(query[key])) dbQuery[key] = query[key]();
else if(_.isObject(query[key])) dbQuery[key] = query[key];
else if(_.isNumber(query[key])) dbQuery[key] = query[key];
else dbQuery[key] = {$regex: query[key], $options: 'i'};
})
}
return Meteor.collections.Subcategories.find(dbQuery, {limit: 20, sort: {updatedAt: -1}});
}
});
// Template.Subcategories.events({
// 'click .trash': function() {
// Meteor.call('deleteSubcategory', this._id);
// console.log("Got here");
// }
// });
Template.Subcategory.helpers({
categoryName: function(id) {
return Meteor.collections.Categories.findOne({_id: id}, {fields: {name: 1}}).name;
}
});
Template.SubcategorySearch.events({
"keyup .searchInput": _.throttle(function(event, template) {
let searchQuery = Session.get('searchQuery') || {};
let searchFields = Session.get('searchFields') || {};
let searchValue = template.$('.searchInput').val();
if(searchValue) {
if(this.number) searchValue = parseFloat(searchValue);
if(this.collection) {
let ids = Meteor.collections[this.collection].find({[this.collectionQueryColumnName]: {$regex: searchValue, $options: 'i'}}, {fields: {[this.collectionResultColumnName]: 1}}).fetch();
//Convert the ids to an array of ids instead of an array of objects containing an id.
for(let i = 0; i < ids.length; i++) {ids[i] = ids[i]._id;}
searchQuery[this.columnName] = {$in: ids};
searchFields[this.columnName] = searchValue;
}
else {
searchFields[this.columnName] = searchQuery[this.columnName] = searchValue;
}
}
else {
//Remove columns from the search query whose values are empty so we don't bother the database with them.
delete searchQuery[this.columnName];
delete searchFields[this.columnName];
}
Session.set('searchQuery', searchQuery);
}, 500)
});
Template.SubcategorySearch.helpers({
searchValue: function() {
let searchFields = Session.get('searchFields');
return (searchFields && searchFields[this.columnName]) ? searchFields[this.columnName] : '';
}
});

View File

@@ -0,0 +1,90 @@
<template name="UserManagement">
<div id="userManagement">
{{#if Template.subscriptionsReady}}
<div class="insert">
{{>UserInsert}}
</div>
<div class="grid">
<table class="dataTable table table-striped table-hover">
<thead>
<tr class="headers">
<th>Username</th>
<th>Email</th>
<th>Roles</th>
<th>Actions</th>
</tr>
<tr class="footers">
<th>{{>UserSearch columnName='username' maxWidth='40' minWidth='30'}}</th>
<th>{{>UserSearch columnName='email' collectionQueryColumnName='name' collection='Items' collectionResultColumnName='_id' maxWidth='150' minWidth='50'}}</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{{#each users}}
{{> User}}
{{/each}}
</tbody>
</table>
</div>
{{else}}
{{/if}}
</div>
</template>
<template name="User">
<tr>
{{#if editing}}
<td><input name="username" class="form-control" type="text" value="{{username}}" required></td>
<td><input name="email" class="form-control" type="text" value="{{email}}" required></td>
<td class="roles center" style="font-size: 1.2em">
{{#each allRoles}}
<span class="role {{getRoleState this}} noselect">{{name}}</span>
{{/each}}
</td>
<td class="center tdLarge"><i class="editorApply fa fa-check-square-o fa-lg noselect clickable" aria-hidden="true"></i>&nbsp;/&nbsp;<i class="editorCancel fa fa-times-circle fa-lg noselect clickable" aria-hidden="true"></i></td>
{{else}}
<td class="tdLarge noselect nonclickable">{{username}}</td>
<td class="tdLarge noselect nonclickable">{{email}}</td>
<td class="tdLarge noselect nonclickable">{{roles}}</td>
<td class="center tdLarge"><i class="userRemove fa fa-times-circle fa-lg noselect clickable" aria-hidden="true"></i>&nbsp;/&nbsp;<i class="userEdit fa fa-pencil-square-o fa-lg noselect clickable" aria-hidden="true"></i></td>
{{/if}}
</tr>
</template>
<template name="UserSearch">
<div class="">
<input type="text" class="searchInput" placeholder="Filter..." value="{{searchValue}}" style="max-width: {{maxWidth}}px; min-width: {{minWidth}}px;"/>
</div>
</template>
<template name="UserInsert">
<form name="insert" autocomplete="off">
<div class="row">
<div class="col-md-3 col-sm-0"></div>
<div class="col-md-6 col-sm-12">
<div class="formGroupHeading">New User</div>
<div class="form-group">
<label class='control-label'>User Name</label>
<input name="username" type="text" class="form-control" required>
</div>
<div class="form-group">
<label class='control-label'>Email</label>
<input name="email" class="form-control" type="text" required/>
</div>
<div class="form-group">
<label class='control-label'>Roles</label>
<div class="roles">
{{#each allRoles}}
<span class="role">{{name}}</span>
{{/each}}
</div>
</div>
<div class="form-group">
<input type="submit" class="btn btn-success" value="Create">
</div>
</div>
<div class="col-md-3 col-sm-0"></div>
</div>
</form>
</template>

87
imports/ui/UserManagement.import.styl vendored Normal file
View File

@@ -0,0 +1,87 @@
#userManagement
margin: 20px 20px
height: 100%
//Flex container options.
flex-flow: column nowrap
justify-content: space-around //Spacing between sales along the primary axis. (vertical spacing for a column layout)
align-items: flex-start //Align the sales within a line along the primary axis. (horizontal alignment for a column layout)
align-content: center //Spacing between lines along the secondary axis. (spacing between columns for a column layout)
display: -webkit-box
display: -moz-box
display: -ms-flexbox
display: -moz-flex
display: -webkit-flex
display: flex
text-align: left
.editor
height: 100%
overflow-y: auto
.insert
flex: none
width: 100%
.col-md-6
padding: 10px 30px 0 30px
background: #EFEFEF
border-radius: 1em
.formGroupHeading
font-size: 1.6em
font-family: "Arial Black", "Arial Bold", Gadget, sans-serif
font-style: normal
font-variant: normal
font-weight: 500
.grid
flex: auto
align-self: stretch
overflow-y: auto
overflow-x: auto
margin-bottom: 20px
border: 0
padding-top: 20px
.table > thead > tr > th
border: 0
padding-top: 0
padding-bottom: 6px
.dataTable
table-layout: fixed
.tdLarge
font-size: 1.5em
.userRemove
color: red
.userEdit
color: darkblue
.editorApply
color: green
.editorCancel
color: red
td.roles
.role
padding: 4px 4px
border: 1px solid #555
border-radius: .25em
background: white
color: #999
cursor: pointer
.selected
color: black
div.roles
padding: 4px 0
.role
padding: 4px 4px
border: 1px solid #555
border-radius: .25em
background: white
color: #999
cursor: pointer
.selected
color: black
.center
vertical-align: middle !important

View File

@@ -0,0 +1,178 @@
import './UserManagement.html';
import '/imports/util/selectize/selectize.js'
Tracker.autorun(function() {
Meteor.subscribe("users", Session.get('searchQuery'));
Meteor.subscribe("roles");
});
Template.UserManagement.helpers({
users: function() {
return Meteor.collections.Users.find({}, {sort: {username: 1}});
}
});
Template.User.onCreated(function() {
this.edited = new ReactiveVar();
});
Template.User.events({
"click .userEdit": function(event, template) {
template.edited.set(this);
},
"click .userRemove": function(event, template) {
let _this = this;
bootbox.confirm({
message: "Delete the user?",
buttons: {confirm: {label: "Yes", className: 'btn-success'}, cancel: {label: "No", className: "btn-danger"}},
callback: function(result) {
if(result) {
Meteor.call('deleteUser', _this._id, function(error, result) {
if(error) {
sAlert.error(error);
}
else {
sAlert.success("User removed.");
}
});
}
}
});
},
"click .editorCancel": function(event, template) {
template.edited.set(undefined);
},
"click .editorApply": function(event, template) {
let username = template.$("input[name='username']").val().trim();
let email = template.$("input[name='email']").val().trim();
let roleSpans = template.$(".roles > span");
let roles = [];
for(let i = 0; i < roleSpans.length; i++) {
if($(roleSpans[i]).hasClass("selected")) {
roles.push($(roleSpans[i]).text());
}
}
//Basic validation.
if(username && username.length > 0 && email && email.length > 0) {
let emails = _.clone(this.emails);
if(!emails || emails.length == 0) {
emails = [{address: email, verified: true}];
}
else {
emails[0].address = email;
emails[0].verified = true;
}
Meteor.call("updateUser", {_id: this._id, username: username, emails: emails, roles: roles}, function(error, result) {
if(error) {
sAlert.error(error);
}
else {
sAlert.success("User updated.");
}
});
}
template.edited.set(undefined);
},
"click .role": function(event, template) {
$(event.target).toggleClass("selected");
}
});
Template.User.helpers({
email: function() {
return this.emails && this.emails.length > 0 ? this.emails[0].address : "";
},
editing: function() {
return Template.instance().edited.get() == this;
},
allRoles: function() {
return Meteor.collections.UserRoles.find();
},
getRoleState: function(role) {
let user = Template.parentData(1);
return user.roles.includes(role.name) ? "selected" : "";
}
});
Template.UserSearch.events({
"keyup .searchInput": _.throttle(function(event, template) {
let searchQuery = Session.get('searchQuery') || {};
let searchFields = Session.get('searchFields') || {};
let searchValue = template.$('.searchInput').val();
if(searchValue) {
if(this.number) searchValue = parseFloat(searchValue);
if(this.collection) {
let ids = Meteor.collections[this.collection].find({[this.collectionQueryColumnName]: {$regex: searchValue, $options: 'i'}}, {fields: {[this.collectionResultColumnName]: 1}}).fetch();
//Convert the ids to an array of ids instead of an array of objects containing an id.
for(let i = 0; i < ids.length; i++) {ids[i] = ids[i]._id;}
searchQuery[this.columnName] = {$in: ids};
searchFields[this.columnName] = searchValue;
}
else {
searchFields[this.columnName] = searchQuery[this.columnName] = searchValue;
}
}
else {
//Remove columns from the search query whose values are empty so we don't bother the database with them.
delete searchQuery[this.columnName];
delete searchFields[this.columnName];
}
Session.set('searchQuery', searchQuery);
}, 500)
});
Template.UserSearch.helpers({
searchValue: function() {
let searchFields = Session.get('searchFields');
return (searchFields && searchFields[this.columnName]) ? searchFields[this.columnName] : '';
}
});
Template.UserInsert.onRendered(function() {
this.$('form[name="insert"]').validator();
});
Template.UserInsert.events({
'click input[type="submit"]': function(event, template) {
event.preventDefault();
template.$('form[name="insert"]').data('bs.validator').validate(function(isValid) {
if(isValid) {
let user = {};
let roles = [];
user.username = template.$('input[name="username"]').val();
user.email = template.$('input[name="email"]').val();
let roleSpans = template.$('.role.selected');
for(let i = 0; i < roleSpans.length; i++) {
roles.push($(roleSpans[i]).text());
}
Meteor.call('insertUser', user, roles, function(error, result) {
if(error) {
sAlert.error(error);
}
else {
sAlert.success("User created.");
}
});
}
});
},
"click .role": function(event, template) {
$(event.target).toggleClass("selected");
}
});
Template.UserInsert.helpers({
allRoles: function() {
return Meteor.collections.UserRoles.find();
}
});

View File

@@ -0,0 +1,65 @@
<template name="Auth_page">
<div class="page auth">
<nav>
<div class="nav-group">
<a href="#" class="js-menu nav-item">
<span class="icon-list-unordered"></span>
</a>
</div>
</nav>
<div class="content-scrollable">
<div class="wrapper-auth">
{{> atForm}}
</div>
</div>
</div>
</template>
<template name="override-atPwdFormBtn">
<button type="submit" class="btn-primary">
{{buttonText}}
</button>
</template>
<template name="override-atTextInput">
<div class="input {{#if isValidating}}validating{{/if}} {{#if hasError}}error{{/if}} {{#if hasSuccess}}has-success{{/if}} {{#if feedback}}has-feedback{{/if}}">
<input type="{{type}}" id="at-field-{{_id}}" name="at-field-{{_id}}" placeholder="{{placeholder}}" autocapitalize="none" autocorrect="off">
{{#if hasIcon}}
<span class="{{iconClass}}"></span>
{{/if}}
{{#if hasError}}
<span>{{errorText}}</span>
{{/if}}
</div>
</template>
<template name="override-atTitle">
<h1 class="title-auth">{{title}}</h1>
<p class="subtitle-auth">Signing in allows you to have private lists</p>
</template>
<template name="override-atError">
<div class="list-errors">
{{#each error}}
<div class="list-item">{{errorText}}</div>
{{/each}}
</div>
</template>
<template name="override-atPwdForm">
<div class="at-pwd-form">
<form role="form" id="at-pwd-form" class="{{disabled}}" novalidate action="#" method="POST">
{{#each fields}}
{{> atInput}}
{{/each}}
{{#if showReCaptcha}}
{{> atReCaptcha}}
{{/if}}
{{> atPwdFormBtn}}
{{#if showForgotPasswordLink}}
{{> atPwdLink}}
{{/if}}
</form>
</div>
</template>

View File

@@ -0,0 +1,11 @@
import { Template } from 'meteor/templating';
import './accounts.html';
// We identified the templates that need to be overridden by looking at the available templates
// here: https://github.com/meteor-useraccounts/unstyled/tree/master/lib
// Template['override-atPwdFormBtn'].replaces('atPwdFormBtn');
// Template['override-atPwdForm'].replaces('atPwdForm');
// Template['override-atTextInput'].replaces('atTextInput');
// Template['override-atTitle'].replaces('atTitle');
// Template['override-atError'].replaces('atError');

8
imports/ui/helpers.js Normal file
View File

@@ -0,0 +1,8 @@
// General use helpers - available to all views.
UI.registerHelper('currentUserName', function() {
if(Meteor.user()){
return Meteor.user().emails[0].address;
}
});

View File

@@ -0,0 +1,106 @@
<template name="Body">
{{> sAlert}}
<div id="layoutBody">
<div class="mainBody">
<div class="leftSidebar">
<i class="fa fa-sign-out fa-2x signOut" aria-hidden="true"></i>
<div class="logo">
<img src="/images/PetitTetonLogo_v2.png"/>
</div>
<ul>
{{#if isInRole 'manage'}}
<li class="{{isActiveRoute 'UserManagement'}}">
<a href="{{pathFor 'UserManagement'}}">
User Management
</a>
</li>
{{/if}}
<li class="{{isActiveRoute 'Sales'}}">
<a href="{{pathFor 'Sales'}}">
Sales <span class="tag">Test Tag</span>
</a>
</li>
<li class="{{isActiveRoute 'Production'}}">
<a href="{{pathFor 'Production'}}">
Production <span class="tag">sample</span>
</a>
</li>
<li class="{{isActiveRoute 'Products'}}">
<a href="{{pathFor 'Products'}}">
Products
</a>
</li>
<li class="{{isActiveRoute 'Pricing'}}">
<a href="{{pathFor 'Pricing'}}">
Pricing
</a>
</li>
<li class="{{isActiveRoute 'ProductTags'}}">
<a href="{{pathFor 'ProductTags'}}">
Tags
</a>
</li>
<li class="{{isActiveRoute 'Measures'}}">
<a href="{{pathFor 'Measures'}}">
Measures
</a>
</li>
</ul>
</div>
<div class="contentBody">
<div class="contentContainer">
<div class="header">
&nbsp;
</div>
<div class="content">
{{> Template.dynamic template=content}}
</div>
</div>
</div>
</div>
<div class="footer">
&copy; Petit Teton LLC 2017
</div>
</div>
</template>
<!--<template name="Body">-->
<!--<div id="layoutBody">-->
<!--<div class="bodyTableRow">-->
<!--<div class="left bodyTableCell">-->
<!--<ul>-->
<!--{{#if isInRole 'manage'}}-->
<!--<li class="{{isActiveRoute 'UserManagement'}}">-->
<!--<a href="{{pathFor 'UserManagement'}}">-->
<!--User Management-->
<!--</a>-->
<!--</li>-->
<!--{{/if}}-->
<!--<li class="{{isActiveRoute 'Sales'}}">-->
<!--<a href="{{pathFor 'Sales'}}">-->
<!--Sales <span class="tag">Test Tag</span>-->
<!--</a>-->
<!--</li>-->
<!--<li class="{{isActiveRoute 'Production'}}">-->
<!--<a href="{{pathFor 'Production'}}">-->
<!--Production <span class="tag">sample</span>-->
<!--</a>-->
<!--</li>-->
<!--</ul>-->
<!--</div>-->
<!--<div class="bodyTableCell">-->
<!--<div class="bodyTable">-->
<!--<div class="header bodyTableRow">-->
<!--&nbsp;-->
<!--</div>-->
<!--<div class="content bodyTableRow">-->
<!--{{> Template.dynamic template=content}}-->
<!--</div>-->
<!--</div>-->
<!--</div>-->
<!--</div>-->
<!--<div class="footer bodyTableRow">-->
<!--&copy; Petit Teton LLC 2017-->
<!--</div>-->
<!--</div>-->
<!--</template>-->

279
imports/ui/layouts/Body.import.styl vendored Normal file
View File

@@ -0,0 +1,279 @@
#layoutBody
width: 100%
height: 100%
display: table
text-align: center
margin: 0
padding: 0
border: 0
.mainBody
display: table
height: 100%
width: 100%
margin: 0
padding: 0
border: 0
.leftSidebar
display: table-cell
position: relative
border: 0
vertical-align: top
padding: 0
text-align: left
width: 220px
height: 100%
//Permalink - use to edit and share this gradient: http://colorzilla.com/gradient-editor/#627d4d+0,1f3b08+100;Olive+3D
background: #90b272 //Old browsers
background: -moz-linear-gradient(-180deg, #90b272 0%, #4d7727 100%) //FF3.6-15
background: -webkit-linear-gradient(-180deg, #90b272 0%,#4d7727 100%) //Chrome10-25,Safari5.1-6
background: linear-gradient(180deg, #90b272 0%,#4d7727 100%) //W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+
font-size: 14px
font-weight: 700
.signOut
position: absolute
left: 10px
top: 10px
color: white
cursor: pointer
.signOut:hover
color: #BBB
.signOut:active
color: black
.logo
text-align: center
margin-top: 20px
ul
padding: 20px 0 0 0
margin: 0
list-style: none
li:first-child
border-top: 1px solid #e4e5e7
li
border-bottom: 1px solid #e4e5e7
color: #96a2ae
text-transform: uppercase
display: block
a
color: black
padding: 15px 20px
cursor: pointer
text-decoration: none
display: block
.tag
padding: .2em .5em
font-size: .7em
color: #fff
white-space: nowrap
vertical-align: baseline
border-radius: .25em
border: 1px solid #000000
float: right
li:hover
background-color: #333
li.active
background-color: #333
a
color: #96a2ae
.contentBody
display: table-cell
//background: #4d7727
.contentContainer
display: table
width: 100%
height: 100%
//border-radius 20px
//border: 0;
background: white
.header
display: table-row
background: #90b272
width: 100%
height: 1px
.content
display: table-row
width: 100%
-webkit-box-shadow: inset 4px 2px 6px 2px rgba(168,165,168,1)
-moz-box-shadow: inset 4px 2px 6px 2px rgba(168,165,168,1)
box-shadow: inset 4px 2px 6px 2px rgba(168,165,168,1)
.footer
display: table-row
height: 1px
text-align: center
background: #4d7727
color: white
//#layoutBody
// width: 100%
// height: 100%
// display: table
// margin: 0
// padding: 0
// border: 0
//
// .bodyTable
// display: table
// margin: 0
// padding: 0
// border: 0
// .bodyTableRow
// display: table-row
// .bodyTableCell
// display: table-cell
//
// .left
// display: table-cell
// border: 0
// vertical-align: top
// padding: 0
// text-align: left
// width: 220px
// height: 100%
// //Permalink - use to edit and share this gradient: http://colorzilla.com/gradient-editor/#627d4d+0,1f3b08+100;Olive+3D
// background: #627d4d //Old browsers
// background: -moz-linear-gradient(-180deg, #627d4d 0%, #1f3b08 100%) //FF3.6-15
// background: -webkit-linear-gradient(-180deg, #627d4d 0%,#1f3b08 100%) //Chrome10-25,Safari5.1-6
// background: linear-gradient(180deg, #627d4d 0%,#1f3b08 100%) //W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+
// font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif
// font-size: 14px
// font-weight: 700
//
// ul
// padding: 50px 0 0 0
// margin: 0
// list-style: none
//
// li:first-child
// border-top: 1px solid #e4e5e7
// li
// border-bottom: 1px solid #e4e5e7
// color: #96a2ae
// text-transform: uppercase
// display: block
//
// a
// color: #96a2ae
// padding: 15px 20px
// cursor: pointer
// text-decoration: none
// display: block
//
// .tag
// padding: .2em .5em
// font-size: .7em
// color: #fff
// white-space: nowrap
// vertical-align: baseline
// border-radius: .25em
// border: 1px solid #000000
// float: right
// li:hover
// background-color: #333
// li.active
// background-color: #333
//
// .header
// height: 1px
// background: #627d4d
// width: 100%
// .content
// background: white
// .footer
// text-align: center
// height: 1px;
// background: #1f3b08
// color: white
//
//.header
// display: table-row
// height: 1px
// background: #627d4d
//
//#layoutBody.body
// display: table
// margin: 0
// padding: 0
// width: 100%
// height: 100%
//
// .body
// display: table-row
// width: 100%
//
// .left
// display: table-cell
// border: 0
// vertical-align: top
// padding: 0
// text-align: left
// width: 220px
// //Permalink - use to edit and share this gradient: http://colorzilla.com/gradient-editor/#627d4d+0,1f3b08+100;Olive+3D
// background: #627d4d //Old browsers
// background: -moz-linear-gradient(-180deg, #627d4d 0%, #1f3b08 100%) //FF3.6-15
// background: -webkit-linear-gradient(-180deg, #627d4d 0%,#1f3b08 100%) //Chrome10-25,Safari5.1-6
// background: linear-gradient(180deg, #627d4d 0%,#1f3b08 100%) //W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+
// font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif
// font-size: 14px;
// font-weight: 700
//
// ul
// padding: 50px 0 0 0
// margin: 0
// list-style: none
//
// li:first-child
// border-top: 1px solid #e4e5e7
// li
// border-bottom: 1px solid #e4e5e7
// color: #96a2ae
// text-transform: uppercase
// display: block
//
// a
// color: #96a2ae
// padding: 15px 20px
// cursor: pointer
// text-decoration: none
// display: block
//
// .tag
// padding: .2em .5em
// font-size: .7em
// color: #fff
// white-space: nowrap
// vertical-align: baseline
// border-radius: .25em
// border: 1px solid #000000
// float: right
// li:hover
// background-color: #333
// li.active
// background-color: #333
// .main
// display: table-row
// background: white
// border: 0
// vertical-align: top
// padding: 0
// text-align: left
//
// .footer
// display: table-row
// text-align: center
// height: 1px;
// background: #1f3b08
// color: white

View File

@@ -0,0 +1,9 @@
import { Template } from 'meteor/templating';
import './Body.html';
Template.Body.events({
"click .signOut": function(event, template) {
AccountsTemplates.logout();
}
});

View File

@@ -0,0 +1,7 @@
<template name="Full">
<div id="full" class="content">
<div class="form">
{{> Template.dynamic template=content}}
</div>
</div>
</template>

11
imports/ui/layouts/Full.import.styl vendored Normal file
View File

@@ -0,0 +1,11 @@
#full.content
//width: 100%
//height: 100%
//background-image: linear-gradient(to bottom, #315481, #918e82 100%)'
background: white
.form
//margin: 5% auto 0 auto
margin: 0 auto
width: 300px

View File

@@ -0,0 +1 @@
import './Full.html';

19
imports/util/blaze.js Normal file
View File

@@ -0,0 +1,19 @@
/**
* Get the parent template instance.
* @param {Number} [levels] How many levels to go up. Default is 1
* @returns {Blaze.TemplateInstance}
*/
Blaze.TemplateInstance.prototype.parentTemplate = function(levels) {
let view = this.view;
levels = (typeof levels === "undefined") ? 1 : levels;
while(view) {
//if(view.name.substring(0, 9) === "Template." && !(levels--)) {
if(view.template && !(levels--)) {
//return view.templateInstance();
return view.templateInstance();
}
view = view.parentView;
}
};

11
imports/util/csv.js Normal file
View File

@@ -0,0 +1,11 @@
let fs = require("fs");
let csv = require('csv-parse');
let result = function(csvFilePath, callback) {
let parser = csv(Assets.getText(csvFilePath), {delimiter: '\t'}, function(error, data) {
if(error) callback(error);
else callback(null, data);
});
};
export default result;

9
imports/util/date.js Normal file
View File

@@ -0,0 +1,9 @@
//
// Add a method to get a timezone adjusted date for an input field that is a date picker.
// Use $('input[name="date"]').val(new Date().toDateInputValue()) to set the date of the input field.
//
Date.prototype.toDateInputValue = (function() {
let local = new Date(this);
local.setMinutes(this.getMinutes() - this.getTimezoneOffset());
return local.toJSON().slice(0,10);
});

31
imports/util/de.combo.import.styl vendored Normal file
View File

@@ -0,0 +1,31 @@
.comboList {
z-index: 1000;
max-height: 160px;
overflow-y: auto;
-moz-box-shadow: 0 2px 3px #ccc;
-webkit-box-shadow: 0 2px 3px #ccc;
box-shadow: 0 2px 3px #ccc;
border: 1px solid #d1d1d1;
list-style-type: none;
padding: 0;
margin: 0;
display: none;
background: white;
li {
display: block;
padding: 5px 10px;
margin: 0;
text-indent: 0
background: white;
}
li.selected {
background-color: #ffe184 !important;
}
li[role='node'] {
font-weight: 800;
}
li[role='leaf'] {
padding-left: 2em;
}
}

398
imports/util/de.combo.js Normal file
View File

@@ -0,0 +1,398 @@
"use strict";
//
// Takes a input form element and a hidden form element (to store the selected id in) along with an array of objects, to build a dropdown select control that allows the user to type part of the selection to filter the list.
// Modified for meteor.
//
(function($) {
let Combo = function($input, $hidden, options) {
let _this = this;
this.focusCounter = 0;
this.$input = $input;
this.$hidden = $hidden;
this.options = $.extend({}, Combo.DEFAULTS, options);
this.$selected = null;
this.$listContainer = $('<div/>', {style: 'position: relative; height: 0;'});
this.$list = $('<ul/>', {role: 'menu', class: this.options.listClass});
//Ensure that if the hidden field exists and changes, that the hidden field's id matches the text in the input field. If not then the hidden id field was changed manually and externally and the text field should be updated.
if(this.$hidden) {
this.$hidden.on('change', hiddenInputChanged);
}
function hiddenInputChanged() {
let id = _this.$hidden.val();
let $li = _this.$list.children("[role!='node']");
for(let i = 0; i < $li.length; i++) {
let $next = $($li[i]);
if($next.data('model').id == id) {
if(_this.$input.val() != $next.text())
_this.$input.val($next.text());
}
}
}
//this.$list.appendTo($input.parent());
this.$list.appendTo(this.$listContainer);
this.$listContainer.appendTo($input.parent());
//Setup the list to highlight the item the user is hovering over, to select the item the user clicks, and to remove the hover styling when the list closes due to a selection being made.
this.$list
.on('mousemove', 'li', function() {
// _this.$list.find(_this.options.selectionClass).removeClass(_this.options.selectionClass);
let $this = $(this);
//Skip nodes.
while($this && $this.attr('role') == 'node') {
$this = $this.next();
}
//If we could find a non-node element then highlight it.
if($this) $this.addClass(_this.options.selectionClass).siblings().removeClass(_this.options.selectionClass);
})
.on('mousedown', 'li', function() {
let $this = $(this);
//Skip nodes.
while($this && $this.attr('role') == 'node') {
$this = $this.next();
}
//If we could find a non-node element then highlight it.
if($this) _this.select($this);
})
.on('mouseup', 'li', function() {
//Remove the selection highlighting.
_this.$list.children().removeClass(_this.options.selectionClass);
//Hide the list.
_this.hide();
});
//Setup the input field to handle opening the list when it receives focus, and close it when it loses focus.
this.$input
.on('focus', $.proxy(_this.focus, _this))
.on('blur', $.proxy(_this.blur, _this));
// this.$listContainer
// .on('focus', $.proxy(_this.focus, _this, "list container"))
// .on('blur', $.proxy(_this.blur, _this, "list container"));
// this.$list
// .on('focus', $.proxy(_this.focus, _this, "list"))
// .on('blur', $.proxy(_this.blur, _this, "list"));
//Handle key events on the input field. Up/down arrows should change the selection in the list. Enter should select an item and close the list. Tab and escape should hide the list before moving to the next focusable element on the page.
this.$input.on('input keydown', function(event) {
switch(event.keyCode) {
case 38: { //Up
let visibles = _this.$list.find('li.visible[role!="node"]');
let selected = visibles.index(visibles.filter('.' + _this.options.selectionClass)) || 0;
_this.highlight(selected - 1);
event.preventDefault();
break;
}
case 40: { //Down
let visibles = _this.$list.find('li.visible[role!="node"]');
let selected = visibles.index(visibles.filter('li.selected')) || 0;
_this.highlight(selected + 1);
event.preventDefault();
break;
}
case 13: //Enter
if(_this.$list.is(':visible')) {
_this.select(_this.$list.find('li.selected'));
event.preventDefault();
}
break;
case 9: //Tab
_this.select(_this.$list.find('li.selected'));
break;
case 27: //Esc
if(_this.$input.hasClass('open')) {
_this.hide();
//Try to stop any default behavior from occurring.
if(event.stopPropagation) event.stopPropagation();
else event.cancelBubble = true; //IE 6-8
event.preventDefault();
return false;
}
else {
return true;
}
default:
_this.filter();
_this.highlight(0);
break;
}
});
//
// Adds one or more elements to the control.
// data: The item or array of items to add. This will be the root tree elements if groupFunctions is provided.
function add(data) {
let groupFunctions = _this.options.groupFunctions;
let addOne = function(data, parent) { //role is optional.
let text = $.isFunction(_this.options.textAttr) ? _this.options.textAttr(data) : data[_this.options.textAttr];
let li = $("<li" + (parent ? " role='leaf'" : "") + ">" + text + "</li>");
li.appendTo(_this.$list);
li.data('model', data);
if(parent) li.data('parent-li', parent);
};
let addOneGroup = function(data, text, children) {
let li = $("<li role='node'>" + text + "</li>");
li.appendTo(_this.$list);
li.data('model', data);
for(let childIndex = 0; childIndex < children.length; childIndex++) {
addOne(children[childIndex], li);
}
};
let addOneBranch = function(data) {
let parents = $.isFunction(groupFunctions.groupParents) ? groupFunctions.groupParents(data) : data;
//Since there may be one or more parents identified for each data element passed to us...
if(Array.isArray(parents)) {
for(let parentIndex = 0; parentIndex < parents.length; parentIndex++) {
addOneGroup(parents[parentIndex], groupFunctions.parentText(parents[parentIndex]), groupFunctions.children(parents[parentIndex]));
}
}
else {
addOneGroup(parents, groupFunctions.parentText(parents), groupFunctions.children(parents));
}
};
if(groupFunctions instanceof Object && $.isFunction(groupFunctions.children) && $.isFunction(groupFunctions.parentText)) {
if(Array.isArray(data)) {
for(let dataIndex = 0; dataIndex < data.length; dataIndex++) {
addOneBranch(data[dataIndex]);
}
}
else {
addOneBranch(data);
}
}
else {
if(Array.isArray(data)) {
for(let dataIndex = 0; dataIndex < data.length; dataIndex++) {
addOne(data[dataIndex]);
}
}
else {
addOne(data);
}
}
//Filter the set of elements so that only those matching the text in the input field are marked as visible.
_this.filter();
}
Tracker.autorun(function() {
this.$list.empty();
//Add the initial set of data.
add(options.cursor.fetch());
}.bind(this));
//Check the hidden input field for an ID, and setup the selection based in it if there is one.
if(this.$hidden && _this.$hidden.val()) {
hiddenInputChanged();
}
};
Combo.DEFAULTS = {
cursor: undefined, //A meteor Cursor.
selection: undefined, //A meteor ReactiveVar whose value will be set to the current selection.
textAttr: 'text', //The attribute of the data elements to use for the name. This can also be a function that takes the data object and returns the text.
idAttr: 'id', //The attribute of the data elements to use for the ID. This can also be a function that takes the data obejct and returns the ID.
// groupFunctions: The object containing three functions: 'groupParent', 'parentText', 'children'.
// groupParents(data) will take a data element and return the objects that best represents the parents of the children (for a multi layer tree, this would be the node just before the leaf nodes).
// parentText(parent) will be passed the group parent and the data object that generated it, and will return the text that represents the path to that parent.
// children(parent) will be passed the group parent (returned by groupParents()), and will return an array of children or leaf nodes for the tree.
groupFunctions: undefined,
filter: true, //Whether to filter the list as the user types.
effects: 'fade',
duration: '200',
listClass: 'de.combo-list',
selectionClass: 'selected' //The class to use for the selected element in the dropdown list.
};
Combo.prototype.select = function($li) {
if($li.length == 0) {
if(this.$input.val() != '') {
this.$input.val("")
if(this.$hidden) this.$hidden.val(undefined).change();
this.filter();
//Note: Don't trigger the select event - for some reason it causes the dropdown to reopen and the control to retain focus when clicking out of the widget.
}
}
else {
if(!this.$list.has($li) || !$li.is('li.visible')) return;
//No need to change selection if the selection has not changed.
if(this.$input.val() != $li.text()) {
this.$input.val($li.text()); //Save the selected text into the text input.
if(this.$hidden) {
this.$hidden.val($li.data('model')[this.options.idAttr]);
this.$hidden.change();
} //Save the ID into the hidden form input if it exists.
this.hide();
this.filter();
//this.trigger('select', $li);
//Set the reactive var for the selection if one is provided.
if(this.options.selection) {
this.options.selection.set($li.data('model'));
}
}
}
};
//Filters the list items by marking those that match the text in the text field as having the class 'visible'.
Combo.prototype.filter = function() {
try {
let search = this.$input.val();
let _this = this;
search = search ? search : "";
search = search.toLowerCase().trim();
//Show all list elements.
this.$list.find('li').addClass('visible').show();
//Hide any node list elements.
this.$list.find('li[role="node"]').removeClass('visible').hide();
if(this.options.filter) {
//Hide non-node elements (leaf nodes) that don't match.
let li = this.$list.children();
let searches = search && search.length > 0 ? search.split(/\s+/) : undefined;
let regexs = searches ? [] : undefined;
if(searches) {
for(let i = 0; i < searches.length; i++) {
regexs.push(new RegExp("\\b" + searches[i]));
}
}
//Iterate over the list elements:
// hide all list items that are nodes;
// show all list items that are not nodes and whose text matches the input value;
// show all node list items associated with visible child list items (they occur after the parent, so the parent will be hidden first, then made visible).
for(let i = 0; i < li.length; i++) {
let $next = $(li[i]);
let node = $next.attr('role') == 'node';
//let hidden = node || $next.text().toLowerCase().indexOf(search) < 0;
let text = $next.text().toLowerCase();
let match = true;
if(!node && searches) {
for(let i = 0; match && i < regexs.length; i++) {
match = regexs[i].test(text)
}
}
//let match = text.match(/\bxxx/gi);
let hidden = node || !match;
if(hidden) $next.removeClass('visible').hide();
//If this isn't hidden and we have a tree with grouping, then turn on the group (parent) associated with this option.
if(!hidden && _this.options.groupFunctions) {
let parent = $next.data('parent-li');
if(!parent.hasClass('visible')) parent.addClass('visible').show();
}
}
//If we hid all elements then hide the whole list.
if(this.$list.find('li.visible').length == 0) this.hide();
}
} catch(e) {
console.log(e);
}
};
Combo.prototype.focus = function() {
this.show();
this.$input.select();
};
Combo.prototype.blur = function() {
this.hide();
this.select(this.$list.find('li.selected'));
};
Combo.prototype.show = function() {
//Position the list relative to the edit field.
this.$list.css({position: 'absolute', top: 0, left: 0, width: this.$input.outerWidth()});
if(!this.$list.is(':visible') && this.$list.find('li.visible').length > 0) {
let fns = {default: 'show', fade: 'fadeIn', slide: 'slideDown'};
let fn = fns[this.options.effects];
this.trigger('show');
this.$input.addClass('open');
this.$list[fn](this.options.duration, $.proxy(this.trigger, this, 'shown'));
}
};
Combo.prototype.hide = function() {
let fns = {default: 'hide', fade: 'fadeOut', slide: 'slideUp'};
let fn = fns[this.options.effects];
this.trigger('hide');
this.$input.removeClass('open');
this.$list[fn](this.options.duration, $.proxy(this.trigger, this, 'hidden'));
};
// goDown: true/false - defaults to true - indicating whether the highlighting should go up or down if the requested item is a node. Nodes cannot be highlighted or selected.
Combo.prototype.highlight = function(index) {
let _this = this;
this.show();
setTimeout(function() {
let visibles = _this.$list.find('li.visible[role!="node"]');
let oldSelected = _this.$list.find('li.' + _this.options.selectionClass).removeClass(_this.options.selectionClass);
let oldSelectedIndex = visibles.index(oldSelected);
if(visibles.length > 0) {
let selectedIndex = (visibles.length + index) % visibles.length;
let selected = visibles.eq(selectedIndex);
let top = selected.position().top;
if(selected.attr('role') != 'node') selected.addClass(_this.options.selectionClass);
if(selectedIndex < oldSelectedIndex && top < 0)
_this.$list.scrollTop(_this.$list.scrollTop() + top);
if(selectedIndex > oldSelectedIndex && top + selected.outerHeight() > _this.$list.outerHeight())
_this.$list.scrollTop(_this.$list.scrollTop() + selected.outerHeight() + 2 * (top - _this.$list.outerHeight()));
}
});
};
Combo.prototype.trigger = function(event) {
let params = Array.prototype.slice.call(arguments, 1);
let args = [event + '.de.combo'];
args.push(params);
if(this.$select) this.$select.trigger.apply(this.$select, args);
this.$input.trigger.apply(this.$input, args);
};
$.fn.buildCombo = function(options) {
for(let index = 0; index < this.length; index++) {
let $next = $(this[index]);
let nextCombo = new Combo($next, $next.siblings('input[type=hidden]').first(), options);
$next.data("de.combo", nextCombo);
}
};
$.fn.getCombo = function() {
if(this.length > 0) {
return $(this[0]).data('de.combo');
}
};
})(jQuery);

3
imports/util/regex.js Normal file
View File

@@ -0,0 +1,3 @@
RegExp.escape = function(s) {
return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
};

View File

@@ -0,0 +1,515 @@
/**
* Copyright Marc J. Schmidt. See the LICENSE file at the top-level
* directory of this distribution and at
* https://github.com/marcj/css-element-queries/blob/master/LICENSE.
*/
;
(function (root, factory) {
if (typeof define === "function" && define.amd) {
define(['./ResizeSensor.js'], factory);
} else if (typeof exports === "object") {
module.exports = factory(require('./ResizeSensor.js'));
} else {
root.ElementQueries = factory(root.ResizeSensor);
}
}(this, function (ResizeSensor) {
/**
*
* @type {Function}
* @constructor
*/
var ElementQueries = function() {
var trackingActive = false;
var elements = [];
/**
*
* @param element
* @returns {Number}
*/
function getEmSize(element) {
if (!element) {
element = document.documentElement;
}
var fontSize = window.getComputedStyle(element, null).fontSize;
return parseFloat(fontSize) || 16;
}
/**
*
* @copyright https://github.com/Mr0grog/element-query/blob/master/LICENSE
*
* @param {HTMLElement} element
* @param {*} value
* @returns {*}
*/
function convertToPx(element, value) {
var numbers = value.split(/\d/);
var units = numbers[numbers.length-1];
value = parseFloat(value);
switch (units) {
case "px":
return value;
case "em":
return value * getEmSize(element);
case "rem":
return value * getEmSize();
// Viewport units!
// According to http://quirksmode.org/mobile/tableViewport.html
// documentElement.clientWidth/Height gets us the most reliable info
case "vw":
return value * document.documentElement.clientWidth / 100;
case "vh":
return value * document.documentElement.clientHeight / 100;
case "vmin":
case "vmax":
var vw = document.documentElement.clientWidth / 100;
var vh = document.documentElement.clientHeight / 100;
var chooser = Math[units === "vmin" ? "min" : "max"];
return value * chooser(vw, vh);
default:
return value;
// for now, not supporting physical units (since they are just a set number of px)
// or ex/ch (getting accurate measurements is hard)
}
}
/**
*
* @param {HTMLElement} element
* @constructor
*/
function SetupInformation(element) {
this.element = element;
this.options = {};
var key, option, width = 0, height = 0, value, actualValue, attrValues, attrValue, attrName;
/**
* @param {Object} option {mode: 'min|max', property: 'width|height', value: '123px'}
*/
this.addOption = function(option) {
var idx = [option.mode, option.property, option.value].join(',');
this.options[idx] = option;
};
var attributes = ['min-width', 'min-height', 'max-width', 'max-height'];
/**
* Extracts the computed width/height and sets to min/max- attribute.
*/
this.call = function() {
// extract current dimensions
width = this.element.offsetWidth;
height = this.element.offsetHeight;
attrValues = {};
for (key in this.options) {
if (!this.options.hasOwnProperty(key)){
continue;
}
option = this.options[key];
value = convertToPx(this.element, option.value);
actualValue = option.property == 'width' ? width : height;
attrName = option.mode + '-' + option.property;
attrValue = '';
if (option.mode == 'min' && actualValue >= value) {
attrValue += option.value;
}
if (option.mode == 'max' && actualValue <= value) {
attrValue += option.value;
}
if (!attrValues[attrName]) attrValues[attrName] = '';
if (attrValue && -1 === (' '+attrValues[attrName]+' ').indexOf(' ' + attrValue + ' ')) {
attrValues[attrName] += ' ' + attrValue;
}
}
for (var k in attributes) {
if(!attributes.hasOwnProperty(k)) continue;
if (attrValues[attributes[k]]) {
this.element.setAttribute(attributes[k], attrValues[attributes[k]].substr(1));
} else {
this.element.removeAttribute(attributes[k]);
}
}
};
}
/**
* @param {HTMLElement} element
* @param {Object} options
*/
function setupElement(element, options) {
if (element.elementQueriesSetupInformation) {
element.elementQueriesSetupInformation.addOption(options);
} else {
element.elementQueriesSetupInformation = new SetupInformation(element);
element.elementQueriesSetupInformation.addOption(options);
element.elementQueriesSensor = new ResizeSensor(element, function() {
element.elementQueriesSetupInformation.call();
});
}
element.elementQueriesSetupInformation.call();
if (trackingActive && elements.indexOf(element) < 0) {
elements.push(element);
}
}
/**
* @param {String} selector
* @param {String} mode min|max
* @param {String} property width|height
* @param {String} value
*/
var allQueries = {};
function queueQuery(selector, mode, property, value) {
if (typeof(allQueries[mode]) == 'undefined') allQueries[mode] = {};
if (typeof(allQueries[mode][property]) == 'undefined') allQueries[mode][property] = {};
if (typeof(allQueries[mode][property][value]) == 'undefined') allQueries[mode][property][value] = selector;
else allQueries[mode][property][value] += ','+selector;
}
function getQuery() {
var query;
if (document.querySelectorAll) query = document.querySelectorAll.bind(document);
if (!query && 'undefined' !== typeof $$) query = $$;
if (!query && 'undefined' !== typeof jQuery) query = jQuery;
if (!query) {
throw 'No document.querySelectorAll, jQuery or Mootools\'s $$ found.';
}
return query;
}
/**
* Start the magic. Go through all collected rules (readRules()) and attach the resize-listener.
*/
function findElementQueriesElements() {
var query = getQuery();
for (var mode in allQueries) if (allQueries.hasOwnProperty(mode)) {
for (var property in allQueries[mode]) if (allQueries[mode].hasOwnProperty(property)) {
for (var value in allQueries[mode][property]) if (allQueries[mode][property].hasOwnProperty(value)) {
var elements = query(allQueries[mode][property][value]);
for (var i = 0, j = elements.length; i < j; i++) {
setupElement(elements[i], {
mode: mode,
property: property,
value: value
});
}
}
}
}
}
/**
*
* @param {HTMLElement} element
*/
function attachResponsiveImage(element) {
var children = [];
var rules = [];
var sources = [];
var defaultImageId = 0;
var lastActiveImage = -1;
var loadedImages = [];
for (var i in element.children) {
if(!element.children.hasOwnProperty(i)) continue;
if (element.children[i].tagName && element.children[i].tagName.toLowerCase() === 'img') {
children.push(element.children[i]);
var minWidth = element.children[i].getAttribute('min-width') || element.children[i].getAttribute('data-min-width');
//var minHeight = element.children[i].getAttribute('min-height') || element.children[i].getAttribute('data-min-height');
var src = element.children[i].getAttribute('data-src') || element.children[i].getAttribute('url');
sources.push(src);
var rule = {
minWidth: minWidth
};
rules.push(rule);
if (!minWidth) {
defaultImageId = children.length - 1;
element.children[i].style.display = 'block';
} else {
element.children[i].style.display = 'none';
}
}
}
lastActiveImage = defaultImageId;
function check() {
var imageToDisplay = false, i;
for (i in children){
if(!children.hasOwnProperty(i)) continue;
if (rules[i].minWidth) {
if (element.offsetWidth > rules[i].minWidth) {
imageToDisplay = i;
}
}
}
if (!imageToDisplay) {
//no rule matched, show default
imageToDisplay = defaultImageId;
}
if (lastActiveImage != imageToDisplay) {
//image change
if (!loadedImages[imageToDisplay]){
//image has not been loaded yet, we need to load the image first in memory to prevent flash of
//no content
var image = new Image();
image.onload = function() {
children[imageToDisplay].src = sources[imageToDisplay];
children[lastActiveImage].style.display = 'none';
children[imageToDisplay].style.display = 'block';
loadedImages[imageToDisplay] = true;
lastActiveImage = imageToDisplay;
};
image.src = sources[imageToDisplay];
} else {
children[lastActiveImage].style.display = 'none';
children[imageToDisplay].style.display = 'block';
lastActiveImage = imageToDisplay;
}
} else {
//make sure for initial check call the .src is set correctly
children[imageToDisplay].src = sources[imageToDisplay];
}
}
element.resizeSensor = new ResizeSensor(element, check);
check();
if (trackingActive) {
elements.push(element);
}
}
function findResponsiveImages(){
var query = getQuery();
var elements = query('[data-responsive-image],[responsive-image]');
for (var i = 0, j = elements.length; i < j; i++) {
attachResponsiveImage(elements[i]);
}
}
var regex = /,?[\s\t]*([^,\n]*?)((?:\[[\s\t]*?(?:min|max)-(?:width|height)[\s\t]*?[~$\^]?=[\s\t]*?"[^"]*?"[\s\t]*?])+)([^,\n\s\{]*)/mgi;
var attrRegex = /\[[\s\t]*?(min|max)-(width|height)[\s\t]*?[~$\^]?=[\s\t]*?"([^"]*?)"[\s\t]*?]/mgi;
/**
* @param {String} css
*/
function extractQuery(css) {
var match;
var smatch;
css = css.replace(/'/g, '"');
while (null !== (match = regex.exec(css))) {
smatch = match[1] + match[3];
attrs = match[2];
while (null !== (attrMatch = attrRegex.exec(attrs))) {
queueQuery(smatch, attrMatch[1], attrMatch[2], attrMatch[3]);
}
}
}
/**
* @param {CssRule[]|String} rules
*/
function readRules(rules) {
var selector = '';
if (!rules) {
return;
}
if ('string' === typeof rules) {
rules = rules.toLowerCase();
if (-1 !== rules.indexOf('min-width') || -1 !== rules.indexOf('max-width')) {
extractQuery(rules);
}
} else {
for (var i = 0, j = rules.length; i < j; i++) {
if (1 === rules[i].type) {
selector = rules[i].selectorText || rules[i].cssText;
if (-1 !== selector.indexOf('min-height') || -1 !== selector.indexOf('max-height')) {
extractQuery(selector);
}else if(-1 !== selector.indexOf('min-width') || -1 !== selector.indexOf('max-width')) {
extractQuery(selector);
}
} else if (4 === rules[i].type) {
readRules(rules[i].cssRules || rules[i].rules);
}
}
}
}
var defaultCssInjected = false;
/**
* Searches all css rules and setups the event listener to all elements with element query rules..
*
* @param {Boolean} withTracking allows and requires you to use detach, since we store internally all used elements
* (no garbage collection possible if you don not call .detach() first)
*/
this.init = function(withTracking) {
trackingActive = typeof withTracking === 'undefined' ? false : withTracking;
for (var i = 0, j = document.styleSheets.length; i < j; i++) {
try {
readRules(document.styleSheets[i].cssRules || document.styleSheets[i].rules || document.styleSheets[i].cssText);
} catch(e) {
if (e.name !== 'SecurityError') {
throw e;
}
}
}
if (!defaultCssInjected) {
var style = document.createElement('style');
style.type = 'text/css';
style.innerHTML = '[responsive-image] > img, [data-responsive-image] {overflow: hidden; padding: 0; } [responsive-image] > img, [data-responsive-image] > img { width: 100%;}';
document.getElementsByTagName('head')[0].appendChild(style);
defaultCssInjected = true;
}
findElementQueriesElements();
findResponsiveImages();
};
/**
*
* @param {Boolean} withTracking allows and requires you to use detach, since we store internally all used elements
* (no garbage collection possible if you don not call .detach() first)
*/
this.update = function(withTracking) {
this.init(withTracking);
};
this.detach = function() {
if (!this.withTracking) {
throw 'withTracking is not enabled. We can not detach elements since we don not store it.' +
'Use ElementQueries.withTracking = true; before domready or call ElementQueryes.update(true).';
}
var element;
while (element = elements.pop()) {
ElementQueries.detach(element);
}
elements = [];
};
};
/**
*
* @param {Boolean} withTracking allows and requires you to use detach, since we store internally all used elements
* (no garbage collection possible if you don not call .detach() first)
*/
ElementQueries.update = function(withTracking) {
ElementQueries.instance.update(withTracking);
};
/**
* Removes all sensor and elementquery information from the element.
*
* @param {HTMLElement} element
*/
ElementQueries.detach = function(element) {
if (element.elementQueriesSetupInformation) {
//element queries
element.elementQueriesSensor.detach();
delete element.elementQueriesSetupInformation;
delete element.elementQueriesSensor;
} else if (element.resizeSensor) {
//responsive image
element.resizeSensor.detach();
delete element.resizeSensor;
} else {
//console.log('detached already', element);
}
};
ElementQueries.withTracking = false;
ElementQueries.init = function() {
if (!ElementQueries.instance) {
ElementQueries.instance = new ElementQueries();
}
ElementQueries.instance.init(ElementQueries.withTracking);
};
var domLoaded = function (callback) {
/* Internet Explorer */
/*@cc_on
@if (@_win32 || @_win64)
document.write('<script id="ieScriptLoad" defer src="//:"><\/script>');
document.getElementById('ieScriptLoad').onreadystatechange = function() {
if (this.readyState == 'complete') {
callback();
}
};
@end @*/
/* Mozilla, Chrome, Opera */
if (document.addEventListener) {
document.addEventListener('DOMContentLoaded', callback, false);
}
/* Safari, iCab, Konqueror */
else if (/KHTML|WebKit|iCab/i.test(navigator.userAgent)) {
var DOMLoadTimer = setInterval(function () {
if (/loaded|complete/i.test(document.readyState)) {
callback();
clearInterval(DOMLoadTimer);
}
}, 10);
}
/* Other web browsers */
else window.onload = callback;
};
ElementQueries.listen = function() {
domLoaded(ElementQueries.init);
};
// make available to common module loader
if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') {
module.exports = ElementQueries;
}
else {
window.ElementQueries = ElementQueries;
ElementQueries.listen();
}
return ElementQueries;
}));

View File

@@ -0,0 +1,227 @@
/**
* Copyright Marc J. Schmidt. See the LICENSE file at the top-level
* directory of this distribution and at
* https://github.com/marcj/css-element-queries/blob/master/LICENSE.
*/
;
(function (root, factory) {
if (typeof define === "function" && define.amd) {
define(factory);
} else if (typeof exports === "object") {
module.exports = factory();
} else {
root.ResizeSensor = factory();
}
}(this, function () {
//Make sure it does not throw in a SSR (Server Side Rendering) situation
if (typeof window === "undefined") {
return null;
}
// Only used for the dirty checking, so the event callback count is limted to max 1 call per fps per sensor.
// In combination with the event based resize sensor this saves cpu time, because the sensor is too fast and
// would generate too many unnecessary events.
var requestAnimationFrame = window.requestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.webkitRequestAnimationFrame ||
function (fn) {
return window.setTimeout(fn, 20);
};
/**
* Iterate over each of the provided element(s).
*
* @param {HTMLElement|HTMLElement[]} elements
* @param {Function} callback
*/
function forEachElement(elements, callback){
var elementsType = Object.prototype.toString.call(elements);
var isCollectionTyped = ('[object Array]' === elementsType
|| ('[object NodeList]' === elementsType)
|| ('[object HTMLCollection]' === elementsType)
|| ('[object Object]' === elementsType)
|| ('undefined' !== typeof jQuery && elements instanceof jQuery) //jquery
|| ('undefined' !== typeof Elements && elements instanceof Elements) //mootools
);
var i = 0, j = elements.length;
if (isCollectionTyped) {
for (; i < j; i++) {
callback(elements[i]);
}
} else {
callback(elements);
}
}
/**
* Class for dimension change detection.
*
* @param {Element|Element[]|Elements|jQuery} element
* @param {Function} callback
*
* @constructor
*/
var ResizeSensor = function(element, callback) {
/**
*
* @constructor
*/
function EventQueue() {
var q = [];
this.add = function(ev) {
q.push(ev);
};
var i, j;
this.call = function() {
for (i = 0, j = q.length; i < j; i++) {
q[i].call();
}
};
this.remove = function(ev) {
var newQueue = [];
for(i = 0, j = q.length; i < j; i++) {
if(q[i] !== ev) newQueue.push(q[i]);
}
q = newQueue;
}
this.length = function() {
return q.length;
}
}
/**
* @param {HTMLElement} element
* @param {String} prop
* @returns {String|Number}
*/
function getComputedStyle(element, prop) {
if (element.currentStyle) {
return element.currentStyle[prop];
} else if (window.getComputedStyle) {
return window.getComputedStyle(element, null).getPropertyValue(prop);
} else {
return element.style[prop];
}
}
/**
*
* @param {HTMLElement} element
* @param {Function} resized
*/
function attachResizeEvent(element, resized) {
if (!element.resizedAttached) {
element.resizedAttached = new EventQueue();
element.resizedAttached.add(resized);
} else if (element.resizedAttached) {
element.resizedAttached.add(resized);
return;
}
element.resizeSensor = document.createElement('div');
element.resizeSensor.className = 'resize-sensor';
var style = 'position: absolute; left: 0; top: 0; right: 0; bottom: 0; overflow: hidden; z-index: -1; visibility: hidden;';
var styleChild = 'position: absolute; left: 0; top: 0; transition: 0s;';
element.resizeSensor.style.cssText = style;
element.resizeSensor.innerHTML =
'<div class="resize-sensor-expand" style="' + style + '">' +
'<div style="' + styleChild + '"></div>' +
'</div>' +
'<div class="resize-sensor-shrink" style="' + style + '">' +
'<div style="' + styleChild + ' width: 200%; height: 200%"></div>' +
'</div>';
element.appendChild(element.resizeSensor);
if (getComputedStyle(element, 'position') == 'static') {
element.style.position = 'relative';
}
var expand = element.resizeSensor.childNodes[0];
var expandChild = expand.childNodes[0];
var shrink = element.resizeSensor.childNodes[1];
var dirty, rafId, newWidth, newHeight;
var lastWidth = element.offsetWidth;
var lastHeight = element.offsetHeight;
var reset = function() {
expandChild.style.width = '100000px';
expandChild.style.height = '100000px';
expand.scrollLeft = 100000;
expand.scrollTop = 100000;
shrink.scrollLeft = 100000;
shrink.scrollTop = 100000;
};
reset();
var onResized = function() {
rafId = 0;
if (!dirty) return;
lastWidth = newWidth;
lastHeight = newHeight;
if (element.resizedAttached) {
element.resizedAttached.call();
}
};
var onScroll = function() {
newWidth = element.offsetWidth;
newHeight = element.offsetHeight;
dirty = newWidth != lastWidth || newHeight != lastHeight;
if (dirty && !rafId) {
rafId = requestAnimationFrame(onResized);
}
reset();
};
var addEvent = function(el, name, cb) {
if (el.attachEvent) {
el.attachEvent('on' + name, cb);
} else {
el.addEventListener(name, cb);
}
};
addEvent(expand, 'scroll', onScroll);
addEvent(shrink, 'scroll', onScroll);
}
forEachElement(element, function(elem){
attachResizeEvent(elem, callback);
});
this.detach = function(ev) {
ResizeSensor.detach(element, ev);
};
};
ResizeSensor.detach = function(element, ev) {
forEachElement(element, function(elem){
if(elem.resizedAttached && typeof ev == "function"){
elem.resizedAttached.remove(ev);
if(elem.resizedAttached.length()) return;
}
if (elem.resizeSensor) {
if (elem.contains(elem.resizeSensor)) {
elem.removeChild(elem.resizeSensor);
}
delete elem.resizeSensor;
delete elem.resizedAttached;
}
});
};
return ResizeSensor;
}));

View File

@@ -0,0 +1,314 @@
/**
* selectize.default.css (v0.12.0) - Default Theme
* Copyright (c) 20132015 Brian Reavis & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
* file except in compliance with the License. You may obtain a copy of the License at:
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
* ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*
* @author Brian Reavis <brian@thirdroute.com>
*/
.selectize-control.plugin-drag_drop.multi > .selectize-input > div.ui-sortable-placeholder
visibility visible !important
background #f2f2f2 !important
background rgba(0, 0, 0, 0.06) !important
border 0 none !important
-webkit-box-shadow inset 0 0 12px 4px #ffffff
box-shadow inset 0 0 12px 4px #ffffff
.selectize-control.plugin-drag_drop .ui-sortable-placeholder::after
content '!'
visibility hidden
.selectize-control.plugin-drag_drop .ui-sortable-helper
-webkit-box-shadow 0 2px 5px rgba(0, 0, 0, 0.2)
box-shadow 0 2px 5px rgba(0, 0, 0, 0.2)
.selectize-dropdown-header
position relative
padding 5px 8px
border-bottom 1px solid #d0d0d0
background #f8f8f8
-webkit-border-radius 3px 3px 0 0
-moz-border-radius 3px 3px 0 0
border-radius 3px 3px 0 0
.selectize-dropdown-header-close
position absolute
right 8px
top 50%
color #303030
opacity 0.4
margin-top -12px
line-height 20px
font-size 20px !important
.selectize-dropdown-header-close:hover
color #000000
.selectize-dropdown.plugin-optgroup_columns .optgroup
border-right 1px solid #f2f2f2
border-top 0 none
float left
-webkit-box-sizing border-box
-moz-box-sizing border-box
box-sizing border-box
.selectize-dropdown.plugin-optgroup_columns .optgroup:last-child
border-right 0 none
.selectize-dropdown.plugin-optgroup_columns .optgroup:before
display none
.selectize-dropdown.plugin-optgroup_columns .optgroup-header
border-top 0 none
.selectize-control.plugin-remove_button [data-value]
position relative
padding-right 24px !important
.selectize-control.plugin-remove_button [data-value] .remove
z-index 1
/* fixes ie bug (see #392) */
position absolute
top 0
right 0
bottom 0
width 17px
text-align center
font-weight bold
font-size 12px
color inherit
text-decoration none
vertical-align middle
display inline-block
padding 2px 0 0 0
border-left 1px solid #0073bb
-webkit-border-radius 0 2px 2px 0
-moz-border-radius 0 2px 2px 0
border-radius 0 2px 2px 0
-webkit-box-sizing border-box
-moz-box-sizing border-box
box-sizing border-box
.selectize-control.plugin-remove_button [data-value] .remove:hover
background rgba(0, 0, 0, 0.05)
.selectize-control.plugin-remove_button [data-value].active .remove
border-left-color #00578d
.selectize-control.plugin-remove_button .disabled [data-value] .remove:hover
background none
.selectize-control.plugin-remove_button .disabled [data-value] .remove
border-left-color #aaaaaa
.selectize-control
position relative
.selectize-dropdown, .selectize-input, .selectize-input input
color #303030
font-family inherit
font-size 13px
line-height 18px
-webkit-font-smoothing inherit
.selectize-input, .selectize-control.single .selectize-input.input-active
background #ffffff
cursor text
display inline-block
.selectize-input
border 1px solid #d0d0d0
padding 8px 8px
display inline-block
width 100%
overflow hidden
position relative
z-index 1
-webkit-box-sizing border-box
-moz-box-sizing border-box
box-sizing border-box
-webkit-box-shadow inset 0 1px 1px rgba(0, 0, 0, 0.1)
box-shadow inset 0 1px 1px rgba(0, 0, 0, 0.1)
-webkit-border-radius 3px
-moz-border-radius 3px
border-radius 3px
.selectize-control.multi .selectize-input.has-items
padding 5px 8px 2px
.selectize-input.full
background-color #ffffff
.selectize-input.disabled, .selectize-input.disabled *
cursor default !important
.selectize-input.focus
-webkit-box-shadow inset 0 1px 2px rgba(0, 0, 0, 0.15)
box-shadow inset 0 1px 2px rgba(0, 0, 0, 0.15)
.selectize-input.dropdown-active
-webkit-border-radius 3px 3px 0 0
-moz-border-radius 3px 3px 0 0
border-radius 3px 3px 0 0
.selectize-input > *
vertical-align baseline
display -moz-inline-stack
display inline-block
zoom 1
*display inline .selectize-control.multi .selectize-input > div
cursor pointer
margin 0 3px 3px 0
padding 2px 6px
background #1da7ee
color #ffffff
border 1px solid #0073bb
.selectize-control.multi .selectize-input > div.active
background #92c836
color #ffffff
border 1px solid #00578d
.selectize-control.multi .selectize-input.disabled > div, .selectize-control.multi .selectize-input.disabled > div.active
color #ffffff
background #d2d2d2
border 1px solid #aaaaaa
.selectize-input > input
display inline-block !important
padding 0 !important
min-height 0 !important
max-height none !important
max-width 100% !important
margin 0 1px !important
text-indent 0 !important
border 0 none !important
background none !important
line-height inherit !important
-webkit-user-select auto !important
-webkit-box-shadow none !important
box-shadow none !important
.selectize-input > input::-ms-clear
display none
.selectize-input > input:focus
outline none !important
.selectize-input::after
content ' '
display block
clear left
.selectize-input.dropdown-active::before
content ' '
display block
position absolute
background #f0f0f0
height 1px
bottom 0
left 0
right 0
.selectize-dropdown
position absolute
z-index 10
border 1px solid #d0d0d0
background #ffffff
margin -1px 0 0 0
border-top 0 none
-webkit-box-sizing border-box
-moz-box-sizing border-box
box-sizing border-box
-webkit-box-shadow 0 1px 3px rgba(0, 0, 0, 0.1)
box-shadow 0 1px 3px rgba(0, 0, 0, 0.1)
-webkit-border-radius 0 0 3px 3px
-moz-border-radius 0 0 3px 3px
border-radius 0 0 3px 3px
.selectize-dropdown [data-selectable]
cursor pointer
overflow hidden
.selectize-dropdown [data-selectable] .highlight
background rgba(125, 168, 208, 0.2)
-webkit-border-radius 1px
-moz-border-radius 1px
border-radius 1px
.selectize-dropdown [data-selectable], .selectize-dropdown .optgroup-header
padding 5px 8px
.selectize-dropdown .optgroup:first-child .optgroup-header
border-top 0 none
.selectize-dropdown .optgroup-header
color #303030
background #ffffff
cursor default
.selectize-dropdown .active
background-color #f5fafd
color #495c68
.selectize-dropdown .active.create
color #495c68
.selectize-dropdown .create
color rgba(48, 48, 48, 0.5)
.selectize-dropdown-content
overflow-y auto
overflow-x hidden
max-height 200px
.selectize-control.single .selectize-input, .selectize-control.single .selectize-input input
cursor pointer
.selectize-control.single .selectize-input.input-active, .selectize-control.single .selectize-input.input-active input
cursor text
.selectize-control.single .selectize-input:after
content ' '
display block
position absolute
top 50%
right 15px
margin-top -3px
width 0
height 0
border-style solid
border-width 5px 5px 0 5px
border-color #808080 transparent transparent transparent
.selectize-control.single .selectize-input.dropdown-active:after
margin-top -4px
border-width 0 5px 5px 5px
border-color transparent transparent #808080 transparent
.selectize-control.rtl.single .selectize-input:after
left 15px
right auto
.selectize-control.rtl .selectize-input > input
margin 0 4px 0 -2px !important
.selectize-control .selectize-input.disabled
opacity 0.5
background-color #fafafa
.selectize-control.multi .selectize-input.has-items
padding-left 5px
padding-right 5px
.selectize-control.multi .selectize-input.disabled [data-value]
color #999
text-shadow none
background none
-webkit-box-shadow none
box-shadow none
.selectize-control.multi .selectize-input.disabled [data-value], .selectize-control.multi .selectize-input.disabled [data-value] .remove
border-color #e6e6e6
.selectize-control.multi .selectize-input.disabled [data-value] .remove
background none
.selectize-control.multi .selectize-input [data-value]
text-shadow 0 1px 0 rgba(0, 51, 83, 0.3)
-webkit-border-radius 3px
-moz-border-radius 3px
border-radius 3px
background-color #1b9dec
background-image -moz-linear-gradient(top, rgb(29, 167, 238, 1), rgb(23, 142, 233, 1))
background-image -webkit-gradient(linear, 0 0, 0 100%, from(rgb(29, 167, 238, 1)), to(rgb(23, 142, 233, 1)))
background-image -webkit-linear-gradient(top, rgb(29, 167, 238, 1), rgb(23, 142, 233, 1))
background-image -o-linear-gradient(top, rgb(29, 167, 238, 1), rgb(23, 142, 233, 1))
background-image linear-gradient(to bottom, rgb(29, 167, 238, 1), rgb(23, 142, 233, 1))
background-repeat repeat-x
filter unquote("progid:DXImageTransform.Microsoft.gradient('#ff1da7ee', '#ff178ee9', 0)")
-webkit-box-shadow 0 1px 0 rgba(0, 0, 0, 0.2), inset 0 1px rgba(255, 255, 255, 0.03)
box-shadow 0 1px 0 rgba(0, 0, 0, 0.2), inset 0 1px rgba(255, 255, 255, 0.03)
.selectize-control.multi .selectize-input [data-value].active
background-color #0085d4
background-image -moz-linear-gradient(top, rgb(0, 143, 216, 1), rgb(0, 117, 207, 1))
background-image -webkit-gradient(linear, 0 0, 0 100%, from(rgb(0, 143, 216, 1)), to(rgb(0, 117, 207, 1)))
background-image -webkit-linear-gradient(top, rgb(0, 143, 216, 1), rgb(0, 117, 207, 1))
background-image -o-linear-gradient(top, rgb(0, 143, 216, 1), rgb(0, 117, 207, 1))
background-image linear-gradient(to bottom, rgb(0, 143, 216, 1), rgb(0, 117, 207, 1))
background-repeat repeat-x
filter unquote("progid:DXImageTransform.Microsoft.gradient('#ff008fd8', '#ff0075cf', 0)")
.selectize-control.single .selectize-input
-webkit-box-shadow 0 1px 0 rgba(0, 0, 0, 0.05), inset 0 1px 0 rgba(255, 255, 255, 0.8)
box-shadow 0 1px 0 rgba(0, 0, 0, 0.05), inset 0 1px 0 rgba(255, 255, 255, 0.8)
background-color #f9f9f9
background-image -moz-linear-gradient(top, rgb(254, 254, 254, 1), rgb(242, 242, 242, 1))
background-image -webkit-gradient(linear, 0 0, 0 100%, from(rgb(254, 254, 254, 1)), to(rgb(242, 242, 242, 1)))
background-image -webkit-linear-gradient(top, rgb(254, 254, 254, 1), rgb(242, 242, 242, 1))
background-image -o-linear-gradient(top, rgb(254, 254, 254, 1), rgb(242, 242, 242, 1))
background-image linear-gradient(to bottom, rgb(254, 254, 254, 1), rgb(242, 242, 242, 1))
background-repeat repeat-x
filter unquote("progid:DXImageTransform.Microsoft.gradient('#fffefefe', '#fff2f2f2', 0)")
.selectize-control.single .selectize-input, .selectize-dropdown.single
border-color #b8b8b8
.selectize-dropdown .optgroup-header
padding-top 7px
font-weight bold
font-size 0.85em
.selectize-dropdown .optgroup
border-top 1px solid #f0f0f0
.selectize-dropdown .optgroup:first-child
border-top 0 none

View File

@@ -0,0 +1,57 @@
<template name="Selectize">
<div class="selectize-control selectize {{#if multiple}}multi{{else}}single{{/if}} {{#if removeButton}}plugin-remove_button{{/if}} {{#if loading}}loading{{/if}}">
<select name={{name}} multiple={{multiple}} id={{id}} data-schema-key={{dataSchemaKey}}>
<option value=""></option>
{{#each getItems}}
<option value={{value}} selected={{selected}}>{{label}}</option>
{{/each}}
</select>
<div class="selectize-input items not-full has-options has-items {{#if disabled}}disabled{{/if}} {{#if open}}focus input-active dropdown-active{{/if}}">
{{#if getPlaceholder}}
<div class="selectize-placeholder {{#if open}}hidden{{/if}}">
{{getPlaceholder}}
</div>
{{/if}}
{{#if inputPosition -1}}
<input type="text" autocomplete="off" tabindex="" class="js-selectizeInput">
{{/if}}
{{#each getItemsSelected}}
<div data-value={{value}} class="item">
{{label}}
{{#if removeButton}}
<a href="#" class="remove" tabindex="-1" title="Remove">×</a>
{{/if}}
</div>
{{#if inputPosition @index}}
<input type="text" autocomplete="off" tabindex="" class="js-selectizeInput">
{{/if}}
{{/each}}
</div>
<div class="selectize-dropdown js-selectize-dropdown {{#unless open}}hidden{{/unless}}">
<div class="selectize-dropdown-content">
{{#each getItemsUnselected}}
<div data-value={{value}} data-index={{@index}} data-selectable class="option {{#if activeOption @index}}active{{/if}} {{isPlaceholder}}">
{{label}}
</div>
{{/each}}
{{#if create}}
{{#if getSearchText}}
<div data-selectable="" data-index='create' class="create {{#if activeOption 'create'}}active{{/if}}">
{{#if createText}}
{{{createText}}}
{{else}}
Add
{{/if}}
<strong>{{getSearchText}}</strong>
</div>
{{/if}}
{{/if}}
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,38 @@
.selectize.selectize-control
position relative
.hidden
display none
select
display none
.selectize-input
input
width 4px
opacity 1
position relative
left 0px
div.selectize-placeholder
color #bbb
background none
border none
&.input-active
div.selectize-placeholder
display none
.selectize-dropdown
width 100% !important
top 36px
&.loading:after
content ""
width 16px
height 16px
//background '/packages/vazco_selectize/img/loading.gif' no-repeat
background 'public/images/loading.gif' no-repeat
position absolute
top 10px
right 10px
z-index 1000
&.multi
.selectize-input
height 36px
.uniPlaceholder
color #bbb

View File

@@ -0,0 +1,696 @@
import './selectize.html';
/* Meteor need globals */
/* eslint strict: 0 */
/* jshint strict: false */
UniSelectize = function (options, template) {
this.items = new ReactiveVar([]);
this.itemsSelected = new ReactiveVar([]);
this.itemsUnselected = new ReactiveVar([]);
this.open = new ReactiveVar(false);
this.loading = new ReactiveVar(false);
this.searchText = new ReactiveVar();
this.activeOption = new ReactiveVar(-1);
this.inputPosition = new ReactiveVar(-1);
this.optionsMethodParams = new ReactiveVar();
this.create = options.create;
this.template = template;
this.multiple = options.multiple;
this.sortMethod = _.isUndefined(options.sortMethod) ? 'label' : options.sortMethod;
this.placeholder = options.placeholder;
this.removeButton = options.removeButton !== false;
this.createMethod = options.createMethod;
this.optionsMethod = options.optionsMethod;
this.optionsPlaceholder = options.optionsPlaceholder;
};
UniSelectize.prototype.triggerChangeEvent = function() {
var self = this;
Meteor.defer(function () {
$(self.template.find('select')).change();
});
};
UniSelectize.prototype.setItems = function (items, value) {
if (!_.isArray(items)) {
console.warn('invalid options format');
}
var values = value && (_.isArray(value) ? value : [value]);
items = _.filter(items, function (item) {
if (!item.value || !item.label) {
console.info('invalid option', item);
return false;
}
return true;
});
var itemValues = items.map(function (item) {
return item.value;
});
_.each(values, function (val) {
if (!_.contains(itemValues, val) && val) {
items.push({
value: val,
label: val
});
}
});
_.each(items, function (item) {
if (_.contains(values, item.value)) {
item.selected = true;
}
});
this.items.set(items);
};
UniSelectize.prototype.addItems = function (newItems, value) {
if (!_.isArray(newItems)) {
console.warn('invalid options format');
}
var values = value && (_.isArray(value) ? value : [value]);
var items = this.items.get();
var itemsValues = items.map(function (item) {
return item.value;
});
_.each(newItems, function (newItem) {
if (!newItem.value || !newItem.label) {
console.info('invalid option', newItem);
return;
}
if (!_.contains(itemsValues, newItem.value)) {
var item = {
value: newItem.value,
label: newItem.label,
selected: newItem.selected
};
if (_.contains(values, newItem.value)) {
item.selected = true;
}
items.push(item);
} else if (typeof newItem.selected !== 'undefined') {
var item = _.find(items, function (item) {
return item.value === newItem.value;
});
item.selected = newItem.selected;
}
});
this.items.set(items);
};
UniSelectize.prototype.removeUnusedItems = function (newItems) {
if (!_.isArray(newItems)) {
console.warn('invalid options format');
}
var items = this.items.get();
var newItemsValues = newItems.map(function (item) {
return item.value;
});
items = _.filter(items, function (item) {
return _.contains(newItemsValues, item.value) || item.selected;
});
this.items.set(items);
};
UniSelectize.prototype.itemsAutorun = function () {
var items = this.items.get();
var itemsSelected = [];
var itemsUnselected = [];
_.each(items, function (item) {
if (item.selected) {
itemsSelected.push(item);
} else {
itemsUnselected.push(item);
}
});
if (this.sortMethod) {
itemsSelected = _.sortBy(itemsSelected, this.sortMethod);
itemsUnselected = _.sortBy(itemsUnselected, this.sortMethod);
}
var itemsSelectedPrev = this.itemsSelected.get();
if (!_.isEqual(itemsSelectedPrev, itemsSelected)) {
this.itemsSelected.set(itemsSelected);
}
if (this.placeholder && this.optionsPlaceholder) {
itemsUnselected.unshift({
value: '',
label: _.isString(this.optionsPlaceholder) ? this.optionsPlaceholder: this.placeholder
});
}
this.itemsUnselected.set(itemsUnselected);
};
UniSelectize.prototype.itemsSelectedAutorun = function () {
var itemsSelected = this.template.uniSelectize.itemsSelected.get();
this.template.uniSelectize.inputPosition.set(itemsSelected.length - 1);
};
UniSelectize.prototype.inputFocus = function () {
var self = this;
Meteor.defer(function () {
var $input = $(self.template.find('input'));
$input.focus();
});
};
UniSelectize.prototype.selectItem = function (value) {
var items = this.items.get();
var multiple = this.multiple;
_.each(items, function (item) {
if (value === '') {
item.selected = false;
} else if (item.value === value) {
item.selected = true;
} else if (!multiple) {
item.selected = false;
}
});
this.setItems(items);
this.triggerChangeEvent();
};
UniSelectize.prototype.unselectItem = function (value, reset) {
var items = this.items.get();
_.each(items, function (item) {
if (item.value === value || reset) {
item.selected = false;
}
});
this.setItems(items);
this.triggerChangeEvent()
};
UniSelectize.prototype.removeItemBeforeInput = function () {
var items = this.itemsSelected.get();
var inputPosition = this.inputPosition.get();
var itemToRemove;
_.each(items, function (item, index) {
if (index === inputPosition) {
itemToRemove = item;
}
});
if (itemToRemove) {
this.unselectItem(itemToRemove.value, false);
}
};
UniSelectize.prototype.removeItemAfterInput = function () {
var items = this.itemsSelected.get();
var inputPosition = this.inputPosition.get();
var itemToRemove;
_.each(items, function (item, index) {
if (index === inputPosition + 1) {
itemToRemove = item;
}
});
if (itemToRemove) {
this.unselectItem(itemToRemove.value, false);
}
};
UniSelectize.prototype.selectActiveItem = function () {
var itemsUnselected = this.getItemsUnselectedFiltered();
var activeOption = this.activeOption.get();
var itemToSelect = itemsUnselected && itemsUnselected[activeOption];
if (activeOption === itemsUnselected.length && this.create) {
this.createItem();
return;
}
itemToSelect && this.selectItem(itemToSelect.value);
if (this.multiple) {
this.open.set(true);
this.inputFocus();
} else {
this.open.set(false);
}
};
UniSelectize.prototype.insertItem = function (item) {
var items = this.items.get();
if (!_.find(items, function (obj) {
if (obj.value === item.value) {
obj.selected = item.selected;
return true;
}
return false;
})) {
items.push(item);
}
this.setItems(items);
this.triggerChangeEvent();
};
UniSelectize.prototype.createItem = function () {
var self = this;
var template = this.template;
var searchText = this.searchText.get();
if (!searchText) {
return false;
}
var item = {
label: searchText,
value: searchText,
selected: true
};
if (template.uniSelectize.createMethod) {
Meteor.call(template.uniSelectize.createMethod, searchText, searchText, function (error, value) {
if (error) {
console.error('universe selectize create method error:', error);
return;
}
Meteor.defer(function () {
item.value = value || item.value;
self.insertItem(item);
});
});
} else {
this.insertItem(item);
}
if (this.multiple) {
this.inputFocus();
} else {
this.open.set(false);
}
};
UniSelectize.prototype.getItemsUnselectedFiltered = function () {
var items = this.itemsUnselected.get();
var searchText = this.searchText.get();
return _.filter(items, function (item) {
if (item.label && item.label.search(new RegExp(searchText, 'i')) !== -1) {
return true;
}
return false;
});
};
UniSelectize.prototype.checkDisabled = function () {
if (this.template.data.disabled) {
throw new Meteor.Error('This field is disabled');
}
};
UniSelectize.prototype.measureString = function (str, $parent) {
if (!str) {
return 0;
}
var $test = $('<test>').css({
position: 'absolute',
top: -99999,
left: -99999,
width: 'auto',
padding: 0,
whiteSpace: 'pre'
}).text(str).appendTo('body');
this.transferStyles($parent, $test, [
'letterSpacing',
'fontSize',
'fontFamily',
'fontWeight',
'textTransform'
]);
var width = $test.width();
$test.remove();
return width;
};
UniSelectize.prototype.transferStyles = function ($from, $to, properties) {
var i, n, styles = {};
if (properties) {
for (i = 0, n = properties.length; i < n; i++) {
styles[properties[i]] = $from.css(properties[i]);
}
} else {
styles = $from.css();
}
$to.css(styles);
};
UniSelectize.prototype.getOptionsFromMethod = function (values) {
var self = this;
var methodName = this.optionsMethod;
var searchText = this.searchText.get();
var params = this.optionsMethodParams.get();
if (!methodName) {
return false;
}
var searchVal = {
searchText: searchText,
values: values || [],
params: params || null
};
self.loading.set(true);
Meteor.call(methodName, searchVal, function (err, options) {
self.loading.set(false);
if (params) {
self.removeUnusedItems(options);
}
self.addItems(options, values);
});
};
Template.Selectize.onCreated(function () {
var template = this;
template.uniSelectize = new UniSelectize(template.data, template);
});
Template.Selectize.onRendered(function () {
var template = this;
template.autorun(function () {
var data = Template.currentData();
var value = data.value;
if (template.uniSelectize.optionsMethod) {
template.uniSelectize.getOptionsFromMethod(value);
} else {
var options = data.options;
template.uniSelectize.setItems(options, value);
}
});
template.autorun(function () {
template.uniSelectize.itemsAutorun();
});
template.autorun(function () {
template.uniSelectize.itemsSelectedAutorun();
});
template.autorun(function () {
var data = Template.currentData();
var methodParams = data.optionsMethodParams;
var params = _.isFunction(methodParams) ? methodParams() : methodParams;
template.uniSelectize.optionsMethodParams.set(params);
});
this.form = $(template.find('select')).parents('form');
this.form.bind('reset', function () {
template.uniSelectize.unselectItem(null, true);
});
});
Template.Selectize.onDestroyed(function () {
if (this.form) {
this.form.unbind('reset');
}
});
Template.Selectize.helpers({
multiple: function () {
var template = Template.instance();
return template.uniSelectize.multiple;
},
removeButton: function () {
var template = Template.instance();
return template.uniSelectize.multiple && template.uniSelectize.removeButton;
},
getItems: function () {
var template = Template.instance();
return template.uniSelectize.items.get();
},
getItemsSelected: function () {
var template = Template.instance();
return template.uniSelectize.itemsSelected.get();
},
getItemsUnselected: function () {
var template = Template.instance();
return template.uniSelectize.getItemsUnselectedFiltered();
},
getSearchText: function () {
var template = Template.instance();
return template.uniSelectize.searchText.get();
},
open: function () {
var template = Template.instance();
return template.uniSelectize.open.get();
},
loading: function () {
var template = Template.instance();
return template.uniSelectize.loading.get();
},
inputPosition: function (position) {
var template = Template.instance();
var inputPosition = template.uniSelectize.inputPosition.get();
return position === inputPosition;
},
activeOption: function (position) {
var template = Template.instance();
var activeOption = template.uniSelectize.activeOption.get();
var itemsUnselected = template.uniSelectize.getItemsUnselectedFiltered();
var createOption = template.uniSelectize.create;
if (activeOption === itemsUnselected.length && createOption) {
return position === 'create';
}
return position === activeOption;
},
getPlaceholder: function () {
var template = Template.instance();
var itemsSelected = template.uniSelectize.itemsSelected.get();
if (itemsSelected.length) {
return false;
}
return template.uniSelectize.placeholder;
},
isPlaceholder: function () {
return this.value === '' ? 'uniPlaceholder' : '';
}
});
Template.Selectize.events({
'click .selectize-input': function (e, template) {
template.uniSelectize.checkDisabled();
template.uniSelectize.inputFocus(template);
template.uniSelectize.getOptionsFromMethod();
},
'keydown input.js-selectizeInput': function (e, template) {
var uniSelectize = template.uniSelectize;
var itemsSelected = uniSelectize.itemsSelected.get();
var itemsUnselected = uniSelectize.getItemsUnselectedFiltered();
var inputPosition = uniSelectize.inputPosition.get();
var activeOption = uniSelectize.activeOption.get();
template.uniSelectize.checkDisabled();
var $input = $(e.target);
var width = template.uniSelectize.measureString($input.val(), $input) + 10;
$input.width(width);
switch (e.keyCode) {
case 8: // backspace
if ($input.val() === '') {
e.preventDefault();
uniSelectize.removeItemBeforeInput();
}
uniSelectize.open.set(true);
uniSelectize.inputFocus();
break;
case 46: // delete
if ($input.val() === '') {
uniSelectize.removeItemAfterInput();
}
uniSelectize.open.set(true);
uniSelectize.inputFocus();
break;
case 27: // escape
$input.blur();
break;
case 13: // enter
e.preventDefault();
if (activeOption === -1 && $input.val() === '') {
break;
}
if (itemsUnselected && itemsUnselected.length > 0) {
uniSelectize.selectActiveItem(template);
uniSelectize.searchText.set('');
$input.val('');
} else if (uniSelectize.create /*&& createOnBlur*/) {
uniSelectize.createItem();
uniSelectize.searchText.set('');
$input.val('');
}
break;
case 37: // left
if (!uniSelectize.multiple) {
break;
}
if (inputPosition > -1) {
uniSelectize.inputPosition.set(inputPosition - 1);
uniSelectize.inputFocus();
}
break;
case 39: // right
if (!uniSelectize.multiple) {
break;
}
if (inputPosition < itemsSelected.length - 1) {
uniSelectize.inputPosition.set(inputPosition + 1);
uniSelectize.inputFocus();
}
break;
case 38: // up
if (activeOption > -1) {
uniSelectize.activeOption.set(activeOption - 1);
}
break;
case 40: // down
if (activeOption < itemsUnselected.length - 1 ||
(activeOption < itemsUnselected.length && uniSelectize.create)) {
uniSelectize.activeOption.set(activeOption + 1);
}
break;
}
if (!template.uniSelectize.multiple && itemsSelected.length) {
return false;
}
},
'keyup input.js-selectizeInput': function (e, template) {
template.uniSelectize.checkDisabled();
var $el = $(e.target);
var value = $el.val();
template.uniSelectize.searchText.set(value);
template.uniSelectize.getOptionsFromMethod();
},
'focus input.js-selectizeInput': function (e, template) {
template.uniSelectize.checkDisabled();
template.uniSelectize.open.set(true);
Meteor.clearTimeout(template.uniSelectize.timeoutId);
},
'change input.js-selectizeInput': function(e, template) {
template.uniSelectize.checkDisabled();
// prevent non-autoform fields changes from submitting the form when autosave is enabled
e.preventDefault();
e.stopPropagation();
},
'blur input.js-selectizeInput': function (e, template) {
template.uniSelectize.checkDisabled();
template.uniSelectize.timeoutId = Meteor.setTimeout(function () {
template.uniSelectize.open.set(false);
}, 500);
},
'scroll .selectize-dropdown-content': function (e, template) {
Meteor.clearTimeout(template.uniSelectize.timeoutId);
template.uniSelectize.timeoutId = Meteor.setTimeout(function () {
template.uniSelectize.open.set(false);
}, 5000);
},
'click .selectize-dropdown-content > div:not(.create)': function (e, template) {
e.preventDefault();
template.uniSelectize.checkDisabled();
var $input = $(template.find('input'));
var itemsUnselected = template.uniSelectize.getItemsUnselectedFiltered();
var itemsUnselectedLength = itemsUnselected && itemsUnselected.length;
template.uniSelectize.selectItem(this.value);
template.uniSelectize.searchText.set('');
$input.val('');
if (template.uniSelectize.multiple && itemsUnselectedLength && this.value) {
template.uniSelectize.inputFocus();
} else {
template.uniSelectize.open.set(false);
}
},
'mouseenter .selectize-dropdown-content > div': function (e, template) {
var $el = $(e.target);
var elIndex = $el.attr('data-index');
var itemsUnselected = template.uniSelectize.getItemsUnselectedFiltered();
if (elIndex === 'create') {
elIndex = itemsUnselected.length;
} else {
elIndex = parseInt(elIndex);
}
template.uniSelectize.activeOption.set(elIndex);
},
'click .create': function (e, template) {
e.preventDefault();
template.uniSelectize.checkDisabled();
var $input = $(template.find('input'));
template.uniSelectize.createItem();
template.uniSelectize.searchText.set('');
$input.val('');
},
'click .remove': function (e, template) {
e.preventDefault();
template.uniSelectize.checkDisabled();
template.uniSelectize.unselectItem(this.value, false);
}
});

View File

550
imports/util/validator.js Normal file
View File

@@ -0,0 +1,550 @@
/*!
* Validator v0.11.5 for Bootstrap 3, by @1000hz
* Copyright 2016 Cina Saffary
* Licensed under http://opensource.org/licenses/MIT
*
* https://github.com/1000hz/bootstrap-validator
*/
/*!
* Modified by Wynne Crisman 10/2016.
* Modifications Copyright 2016 Wynne Crisman
* Modifications licensed under http://opensource.org/licenses/MIT
*
* Added commenting and formatting to tabs - wtf spaces?! (side note: If you are coding in Notepad or vim, please seek a therapist's help immediately. Perhaps you haven't heard that there are free tools (Notepad++, Emacs) that do formatting for you, or you can go all out and use an actual integrated development environment like a professional developer who values their time might.)
* Added semicolons; While not strictly required, they help with readability (knowing, without thinking, where a line ends is really handy when you code all day) and are required by some pretty printing tools for JS.
* Removed code that used && as an if block - this is really hard to read, and will confuse even seasoned developers - there is no reason to do it (any performance improvement is due entirely to crappy VM implementations - there is no hard evidence this is worth the aggravation).
* Added an optional parameter to validate(..) - a callback function may be passed now. The callback will be provided with a boolean parameter 'isValid' indicating whether the form validated. This allows developers to easily force a validation and take an action on the result (submit the form?).
* Added documentation at the top of this file to provide basic usage information.
* Added highlighting on input-group elements in addition to form-group elements.
*/
/*
* USAGE INFO
*
* Getting Started:
* Create a form along with any fields in HTML '<form id='myform'>...</form>', then in your javascript block call: $('#myform').validator(); to initialize the validator.
* Your form elements may specify additional standard validation tags to help the validator know what to do (for example add 'required' to any form element to tell the validator which elements must have data).
* Form elements should be put into form-groups, placing related elements into a common group. Form groups are simply Div's with the class='form-group'.
* Useful Functions:
* $(form).validator() - Initializes the validator for a form. This does not return the validator instance, but it does attach it to the form element.
* $(form).data('bs.validator') - Gets the validator object for the given form. The validator object is stored as data attached to the form with the 'bs.validator' key.
* validate(fn) - Forces validation to occur and takes an optional callback which will be passed a flag (boolean) indicating the success of the validation (isValid).
* reset() - Resets the form's validation status. Clears all error information, without turning validation off.
* update() - Updates the collection of fields that require validation. Call this after making changes to the form, including initializing any form elements that may generate HTML (such as Select2).
*/
+function ($) {
'use strict';
// VALIDATOR CLASS DEFINITION
// ==========================
//Gets the value of the HTML element.
function getValue($el) {
if($el.is('[type="checkbox"]')) {
return $el.prop('checked')
}
else if($el.is('[type="radio"]')) {
return !!$('[name="' + $el.attr('name') + '"]:checked').length
}
else {
return $el.val();
}
}
//Gets the actual element to perform the validation on. This returns the input element if the input element isn't a surrogate for a hidden form element.
//Some widget libraries (such as Select2) hide the actual element that holds the data, and use custom graphical elements for the control and display logic.
//We need the actual element that holds the value, and not the display elements which fire the events we are interested in (focusout, etc).
function getActualWidget($el) {
if($el.hasClass('select2-search__field')) { //Handle Select2 multi-select controls.
//Select2 creates a structure of span elements whose parent span has the .select2 class and whose sibling is a hidden select element that contains the actual selection to be validated.
return $el.parents('.select2').siblings('select');
}
else {
return $el;
}
}
//The opposite of getActualWidget($el). Gets the visible surrogate widget for the actual hidden form widget.
//The actual hidden widget holds the value and acts like a form element when submitting the form, while the surrogate has the display and functionality desired by the view.
function getSurrogate($el) {
if($el.hasClass('select2-hidden-accessible')) { //Handle Select2 multi-select controls.
//Select2 creates a structure of span elements whose parent span has the .select2 class and whose sibling is a hidden select element that contains the actual selection to be validated.
return $el.siblings('.select2').find('.select2-selection');
}
else {
return $el;
}
}
var Validator = function(element, options) {
this.options = options;
this.validators = $.extend({}, Validator.VALIDATORS, options.custom);
this.$element = $(element);
this.$btn = $('button[type="submit"], input[type="submit"]')
.filter('[form="' + this.$element.attr('id') + '"]')
.add(this.$element.find('input[type="submit"], button[type="submit"]'));
this.update();
//Register for the events (uses a namespace for easy de-registration).
this.$element.on('input.bs.validator change.bs.validator focusout.bs.validator', $.proxy(this.onInput, this));
this.$element.on('submit.bs.validator', $.proxy(this.onSubmit, this));
this.$element.on('reset.bs.validator', $.proxy(this.reset, this));
//TODO: What is '[data-match]' ????
//This will find some kind of matching elements in the form and when the validation event is detected on the match target (retrieved from the data-match element's 'match' data), a validation event will also be fired on the data-match element.
this.$element.find('[data-match]').each(function() {
var $this = $(this);
var target = $this.data('match');
//Register an event handler on the match target element and if the match target element has a value, then fire the validator event on the data-match element also.
$(target).on('input.bs.validator', function(e) {
if(getValue($this)) $this.trigger('input.bs.validator');
});
});
//Force validation on any elements that start with values (ignore those that don't).
//Filter the HTML elements we will be validating to get the set that contains values (ignore those that don't have values), then trigger a 'focusout' event on those elements (forcing validation as we start the Validator up).
this.$inputs.filter(function() {
return getValue($(this))
}).trigger('focusout');
//Diable automatic native validation.
this.$element.attr('novalidate', true);
//Update the submit elements based on the current error state.
this.toggleSubmit();
};
Validator.VERSION = '0.11.5';
Validator.INPUT_SELECTOR = ':input:not([type="hidden"], [type="submit"], [type="reset"], button, .select2-search__field)'
Validator.SURROGATE_SELECTOR = '.select2 .select2-selection';
Validator.FOCUS_OFFSET = 20;
Validator.DEFAULTS = {
delay: 500,
html: false,
disable: true,
focus: true,
custom: {},
errors: {
match: 'Does not match',
minlength: 'Not long enough'
},
feedback: {
success: 'glyphicon-ok',
error: 'glyphicon-remove'
}
};
//The default set of validators to be used.
Validator.VALIDATORS = {
'native': function($el) {
var el = $el[0];
if(el.checkValidity) {
return !el.checkValidity() && !el.validity.valid && (el.validationMessage || "error!");
}
},
'match': function($el) {
var target = $el.data('match');
return $el.val() !== $(target).val() && Validator.DEFAULTS.errors.match;
},
'minlength': function($el) {
var minlength = $el.data('minlength');
return $el.val().length < minlength && Validator.DEFAULTS.errors.minlength;
}
};
//Collects a list of HTML elements that require validation using the INPUT_SELECTOR option supplied when the Validator was created, and the default data-validate attribute that can be supplied in the HTML tags.
Validator.prototype.update = function() {
this.$inputs = this.$element.find(Validator.INPUT_SELECTOR)
.add(this.$element.find('[data-validate="true"]'))
.not(this.$element.find('[data-validate="false"]'));
this.$surrogates = this.$inputs.filter('.select2-hidden-accessible').map(function(el) {
return getSurrogate($(this));
});
return this;
};
//An event handler that will perform validation on a HTML element when that element fires an event.
Validator.prototype.onInput = function(e) {
var self = this;
var $surrogate = $(e.target); //Wrapper the event target with a jquery object.
var $el = getActualWidget($surrogate); //Get the actual (hidden) form widget if there is one, otherwise $el will equal (===) $surrogate.
var deferErrors = e.type !== 'focusout';
//If the event target is not in the set of HTML elements that require validation, then ignore it.
if(!this.$inputs.is($el)) return;
//Validate the HTML element and update the submit button's state as necessary.
this.validateInput($el, $surrogate, deferErrors).done(function() {
self.toggleSubmit();
})
};
//Runs a complete validation. Returns this validator (does not return the result of the validation since the validation is performed in the future).
//@param fn An optional function that will be run after validating, and which will be passed a boolean indicating whether the form passed validation (isValid). [Added by Wynne Crisman 10/2016]
// Allows the user to validate and do something based on the form's validity without any complicated gyrations:
// $form.data('bs.validator').validate(function(isValid) { if(isValid) { /* Allow form submittal. */ }});
Validator.prototype.validate = function(fn) {
var self = this;
//Create a collection of promises by validating each input HTML element, then update the submit logic based on the presence of errors and set the focus to the first element with an error.
$.when(this.$inputs.map(function(el) {
var $el = $(this); //Wrapper the event target with a jquery object.
var $surrogate = getSurrogate($el); //Gets the surrogate widget used to manage the display and functionality. Will === $el if there isn't a surrogate.
return self.validateInput($el, $surrogate, false);
})).then(function() {
self.toggleSubmit();
self.focusError();
//Call the callback, passing whether the form is valid (boolean).
if(fn instanceof Function) fn(!self.isIncomplete() && !self.hasErrors());
});
return this;
};
//Validates the value of an HTML element and returns a promise.
Validator.prototype.validateInput = function($el, $surrogate, deferErrors) {
//var value = getValue($el);
var prevErrors = $el.data('bs.validator.errors'); //Get the errors from a previous run of the validator.
var errors;
if($el.is('[type="radio"]')) $el = this.$element.find('input[name="' + $el.attr('name') + '"]');
//Create a validator event indicating we are about to validate.
var e = $.Event('validate.bs.validator', {relatedTarget: $el[0]});
//Fire the event.
this.$element.trigger(e);
//If the event handlers flag that we shouldn't validate then stop here.
if(e.isDefaultPrevented()) return;
var self = this;
//Run the validators on our HTML element and handle any errors.
return this.runValidators($el).done(function(errors) {
//Save the errors by attaching them to the HTML element.
$el.data('bs.validator.errors', errors);
//If there were no errors then call clearErrors() to remove the error styling on the view, otherwise we do have errors and we either show them immediately (change styling & HTML) or defer the showing of them until later if told to defer.
!errors.length ? self.clearErrors($el) : (deferErrors ? self.defer($el, self.showErrors) : self.showErrors($el));
//If this is the first run of the validator for this element (prevErrors == undefined), or the previous errors are the same as the new errors (nothing changed), then fire an event notifying listeners of the change in error status.
if(!prevErrors || errors.toString() !== prevErrors.toString()) {
if(errors.length) {
e = $.Event('invalid.bs.validator', {relatedTarget: $el[0], detail: errors});
}
else {
e = $.Event('valid.bs.validator', {relatedTarget: $el[0], detail: prevErrors});
}
//Fire the event.
self.$element.trigger(e);
}
//Update the view's submit elements based on the error state of the form.
self.toggleSubmit();
//Fire an event on the form indicating the related element was validated (irregardless of whether it has errors or not).
self.$element.trigger($.Event('validated.bs.validator', {relatedTarget: $el[0]}));
})
};
//Returns a Promise where the result is the array of errors (error messages) found by the validator. The promise will be passed an empty array if there are no errors.
Validator.prototype.runValidators = function($el) {
var errors = [];
var deferred = $.Deferred();
//If using deferred validation then reject (avoid recursive calls?).
if($el.data('bs.validator.deferred')) $el.data('bs.validator.deferred').reject();
//Set that validation is deferred (avoid recursive calls?).
$el.data('bs.validator.deferred', deferred);
function getValidatorSpecificError(key) {
return $el.data(key + '-error');
}
function getValidityStateError() {
var validity = $el[0].validity;
return validity.typeMismatch ? $el.data('type-error')
: validity.patternMismatch ? $el.data('pattern-error')
: validity.stepMismatch ? $el.data('step-error')
: validity.rangeOverflow ? $el.data('max-error')
: validity.rangeUnderflow ? $el.data('min-error')
: validity.valueMissing ? $el.data('required-error')
: null;
}
function getGenericError() {
return $el.data('error');
}
function getErrorMessage(key) {
return getValidatorSpecificError(key)
|| getValidityStateError()
|| getGenericError();
}
//For each validator in the validator hashmap (key value pair), call the function f(key,validator) using this as the context (so it has access to 'this' from this method).
//See the VALIDATORS hashmap defined above (used as the default set of validator functions).
$.each(this.validators, $.proxy(function(key, validator) {
var error;
//If the HTML element has a value OR is required, AND there is data attached to the HTML element for the current validator OR the validator is 'native', AND the validator produces an error.
if((getValue($el) || $el.attr('required')) && ($el.data(key) || key == 'native') && (error = validator.call(this, $el))) {
//Allow generic or state or specific validator errors to trump the validation error.
error = getErrorMessage(key) || error;
//If the error is not in the list then add it. Use jquery.inArray instead of indexOf since indexOf does not exist in some IE versions.
if(!~$.inArray(error, errors)) errors.push(error);
}
}, this));
//Get errors from the server if a 'remote' URL is provided (allow it to check the element's value and supply additional errors), and then resolve the promise with the collected errors.
//If there are no errors AND the HTML element has a value AND the HTML element has 'remote' data associated, then make an AJAX call sometime in the future to pass the server the element name and value, otherwise resolve the promise passing the error list.
if(!errors.length && getValue($el) && $el.data('remote')) {
this.defer($el, function() {
var data = {};
//Assign elementName = elementValue to the data object.
data[$el.attr('name')] = getValue($el);
//Make an AJAX GET call to the URL stored in the element's 'remote' data, passing the object containing the element's name and its value. If there is an error in the AJAX call then add the error to the error list.
$.get($el.data('remote'), data)
.fail(function(jqXHR, textStatus, error) {
errors.push(getErrorMessage('remote') || error)
})
.always(function() {
deferred.resolve(errors)
})
})
}
else deferred.resolve(errors);
//Return the promise that will be passed the collected errors (array of strings) when the error checking is complete.
return deferred.promise();
};
//Changes the focus to the first element with an error.
Validator.prototype.focusError = function() {
if(!this.options.focus) return;
var $input = this.$element.find(".has-error:first"); // :input
if($input.length === 0) return;
//Find the input field if it is a input group or form group that has the error markings.
if($input.hasClass('form-group') || $input.hasClass('input-group')) {
$input = $input.find('input');
}
//If this is a select2 control then look for the child of a sibling that has the .select2-selection class and use it instead.
if($input.hasClass('select2-hidden-accessible')) {
$input = $input.parent().find('.select2-selection');
}
$('html, body').animate({scrollTop: $input.offset().top - Validator.FOCUS_OFFSET}, 250);
$input.focus();
};
//Alters the display to highlight errors in the form.
Validator.prototype.showErrors = function($el) {
var method = this.options.html ? 'html' : 'text';
var errors = $el.data('bs.validator.errors');
var $group = $el.closest('.form-group,.input-group');
var $block = $group.find('.help-block.with-errors');
var $feedback = $group.find('.form-control-feedback');
if(!errors.length) return;
errors = $('<ul/>')
.addClass('list-unstyled')
.append($.map(errors, function(error) {
return $('<li/>')[method](error)
}));
if($block.data('bs.validator.originalContent') === undefined)
$block.data('bs.validator.originalContent', $block.html());
$block.empty().append(errors);
//Add the 'has-error' and 'has-danger' classes to the grouping.
$group.addClass('has-error has-danger');
//If this is a select2 control then look for the child of a sibling that has the .select2-selection class and use it instead.
if($el.hasClass('select2-hidden-accessible')) {
$el.parent().find('.select2-selection').addClass('has-error has-danger');
}
//If the group has the 'has-feedback' class then remove any success markings and add error markings.
if($group.hasClass('has-feedback')) {
$feedback.removeClass(this.options.feedback.success);
$feedback.addClass(this.options.feedback.error);
$group.removeClass('has-success');
}
};
//Clears the display of all error information.
Validator.prototype.clearErrors = function($el) {
var $group = $el.closest('.form-group,.input-group');
var $block = $group.find('.help-block.with-errors');
var $feedback = $group.find('.form-control-feedback');
$block.html($block.data('bs.validator.originalContent'));
$group.removeClass('has-error has-danger has-success');
//Clean the sibling controls for select2.
$el.parent().find('.select2 .select2-selection').removeClass('has-error has-danger has-success');
$group.hasClass('has-feedback')
&& $feedback.removeClass(this.options.feedback.error)
&& $feedback.removeClass(this.options.feedback.success)
&& getValue($el)
&& $feedback.addClass(this.options.feedback.success)
&& $group.addClass('has-success');
};
//Returns whether the form has any errors.
//Warning: Calling this externally is dangerous because validation is asynchronous and this call will not wait for validation to finish.
// Better to call '$form.data('bs.validator').validate(function(isValid) {...})' instead to ensure validation is done.
Validator.prototype.hasErrors = function() {
function fieldErrors() {
return !!($(this).data('bs.validator.errors') || []).length;
}
return !!this.$inputs.filter(fieldErrors).length;
};
//Returns whether the form is not complete (appears this is due to a text input containing only spaces when a value is required).
//Warning: Calling this externally is dangerous because validation is asynchronous and this call will not wait for validation to finish.
// Better to call '$form.data('bs.validator').validate(function(isValid) {...})' instead to ensure validation is done.
Validator.prototype.isIncomplete = function() {
function fieldIncomplete() {
var value = getValue($(this));
return !(typeof value == "string" ? $.trim(value) : value);
}
return !!this.$inputs.filter('[required]').filter(fieldIncomplete).length;
};
//Prevents form submittal (by setting the preventDefault flag on the event) if the form is not valid or complete.
Validator.prototype.onSubmit = function(e) {
this.validate();
if(this.isIncomplete() || this.hasErrors()) e.preventDefault();
};
//Enables or disables the submit button based on the validation state of the form.
Validator.prototype.toggleSubmit = function() {
if(!this.options.disable) return;
this.$btn.toggleClass('disabled', this.isIncomplete() || this.hasErrors())
};
//Defers the callback function so that it runs sometime in the future. Only one callback may be deferred at any given time. How far into the future it will be deferred depends on the 'delay' option.
//Used internally to defer validation as the user interacts with the form such that we don't validated constantly (for every character typed).
Validator.prototype.defer = function($el, callback) {
//Wrapper the callback so it's 'this' variable will be the validator.
callback = $.proxy(callback, this, $el);
//If there isn't a delay then run the callback immediately.
if(!this.options.delay) return callback();
//Clear any callback already being delayed.
window.clearTimeout($el.data('bs.validator.timeout'));
//Delay the execution of the callback.
$el.data('bs.validator.timeout', window.setTimeout(callback, this.options.delay));
};
//Resets the form to its state, removing all validator modifications (but not removing the validator). Returns the validator reference.
Validator.prototype.reset = function() {
this.$element.find('.form-control-feedback').removeClass(this.options.feedback.error).removeClass(this.options.feedback.success);
this.$inputs
.removeData(['bs.validator.errors', 'bs.validator.deferred'])
.each(function() {
var $this = $(this);
var timeout = $this.data('bs.validator.timeout');
window.clearTimeout(timeout) && $this.removeData('bs.validator.timeout');
});
this.$element.find('.help-block.with-errors')
.each(function() {
var $this = $(this);
var originalContent = $this.data('bs.validator.originalContent');
$this
.removeData('bs.validator.originalContent')
.html(originalContent);
});
this.$btn.removeClass('disabled');
this.$element.find('.has-error, .has-danger, .has-success').removeClass('has-error has-danger has-success');
return this;
};
//Removes the validator completely and resets the state.
Validator.prototype.destroy = function() {
this.reset();
//Remove attributes, data, and event handlers from all elements.
this.$element
.removeAttr('novalidate')
.removeData('bs.validator')
.off('.bs.validator');
//Remove event handlers from input elements.
this.$inputs.off('.bs.validator');
this.options = null;
this.validators = null;
this.$element = null;
this.$btn = null;
return this;
};
// VALIDATOR PLUGIN DEFINITION
// ===========================
//Plugin constructor.
function Plugin(option) {
return this.each(function() {
var $this = $(this);
var options = $.extend({}, Validator.DEFAULTS, $this.data(), typeof option == 'object' && option);
var data = $this.data('bs.validator');
if(!data && option == 'destroy') return;
if(!data) $this.data('bs.validator', (data = new Validator(this, options)));
if(typeof option == 'string') data[option]();
});
}
var old = $.fn.validator;
//JQuery integration.
$.fn.validator = Plugin;
$.fn.validator.Constructor = Validator;
// VALIDATOR NO CONFLICT
// =====================
$.fn.validator.noConflict = function() {
$.fn.validator = old;
return this;
};
// VALIDATOR DATA-API
// ==================
$(window).on('load', function() {
$('form[data-toggle="validator"]').each(function() {
var $form = $(this);
Plugin.call($form, $form.data());
});
});
}(jQuery);