-
Notifications
You must be signed in to change notification settings - Fork 1
/
wizard.js
740 lines (621 loc) · 21.1 KB
/
wizard.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
function maWizardConstructor() {
var dataContext;
var dataContextDep = new Deps.Dependency;
var onSaveFailure;
var onSaveFailureDep = new Deps.Dependency();
var collection;
var schema;
var collectionDefs;
var validationContext;
var activeFields;
var template;
var isModal;
var initializedTemplates = [];
var dbReplica;
var self = this;
/**
* Returns a default value whose type is coherent with respect to the
* 'key' data type reported in the schema
* @param {String} key - Key for the schema field of interest
*/
var getDefaultValue = function(key) {
var keyType = schema.schema(key).type();
// for numbers an empty string is returned and the clean() method
// will perform the appropriate normalization
if(typeof keyType === 'string' || typeof keyType === 'number')
return "";
else if(typeof keyType === 'boolean')
return false;
else if(Array.isArray(keyType))
return [];
};
var loadFromDatabase = function(id) {
// if no id is specified I am adding a new object
if(id === undefined)
dbReplica = {};
else
dbReplica = collection.findOne(id);
return dbReplica;
};
/**
* Returns an object whose structure is specified by the schema passed
* to init(). All keys have default value
*/
this.buildObjectFromSchema = function() {
var obj = {};
_.each(schema.objectKeys(), function(key) {
obj[key] = getDefaultValue(key);
});
obj["_id"] = undefined;
return obj;
};
/**
* Set the data context and invalidates getDataContext() computations
* @param {Object} context - New data context
*/
this.setDataContext = function(context) {
dataContext = context;
dataContextDep.changed();
};
/**
* Reactively gets the data context
*/
this.getDataContext = function() {
dataContextDep.depend();
return dataContext;
};
/**
* Gets the maSimpleSchema validation context.
*/
this.getValidationContext = function() {
return validationContext;
};
/**
* If 'field' is specified, returns the SimpleSchema field definition.
* With no parameter it returns the whole SimpleSchema definition.
* @param {String} [field] - SimpleSchema field name
*/
this.getSchemaObj = function(field) {
// field could be undefined, case in which the whole schema
// object is returned
if(schema)
return schema.schema(field);
return undefined;
};
/**
* Gets the used maSimpleSchema object.
*/
this.getSchema = function() {
return schema;
};
/**
* Returns true if the specified field is active (this means it is shown when using standard components).
* @param {String} field - SimpleSchema field name
*/
this.isFieldActive = function(field) {
// if the field is of the type mainField.N.field we
// must replace the number N with $
var normField = field.replace(/\.\d\./, ".$.");
if(activeFields) {
if(activeFields.indexOf(normField) > -1)
return true;
else
return false;
}
return true;
};
/**
* Returns an array containing all active fields (those shown when using standard components).
*/
this.getActiveFields = function() {
return activeFields;
};
/**
* Returns `true` if the current template is treated as a Bootstrap modal (as set in `init()`).
* False otherwise.
*/
this.isModal = function() {
if(isModal)
return true;
else
return false;
};
/**
* Returns the template name
*/
this.getTemplateName = function() {
return template;
};
/**
* Returns a FieldValuePair object to use with other maWizard methods.
* @param {String} field - SimpleSchema field name
* @param {*} - Field value
*/
this.buildFieldValuePair = function(field, value) {
return new FieldValuePair(field, value);
};
/**
* Extracts the field to which the HTML element is linked (specified by
* the `data-schemafield` attribute) and the displayed value. Returns a
* FieldValuePair containing such information.
* The 'elem' param should represents a text input, textarea, checkbox,
* select or multiselect element.
* @param {Object} elem - HTML DOM element
*/
this.parseHTMLElement = function(elem) {
// extracting field name from data-schemafield attribute
var field = elem.getAttribute('data-schemafield');
var inputType = elem.type;
// if the input is a checkbox we want to get its checked state,
// for a multiple select we want the selected elements and for
// the other inputs we simply get the value
var value;
if(inputType === "checkbox")
value = elem.checked;
else if(inputType === "select-multiple") {
var ops = _.filter(elem.options, function(elem) {
if(elem.selected)
return true;
});
value = _.map(ops, function(elem) {
return elem.value;
});
}
else value = elem.value;
// constructing the object to pass to validateOne(obj, key)
var fieldValuePair = this.buildFieldValuePair(field, value);
return fieldValuePair;
};
/**
* Calls parseHTMLElem() on 'elem' and saves the returned
* FieldValuePair in the data context
* @param {Object} elem - HTML DOM element
*/
this.saveHTMLElement = function(elem) {
var toSave = this.parseHTMLElement(elem);
this.processFieldValuePair(toSave);
};
/**
* Validates 'fieldValuePair' and saves the content in the
* data context. The save is performed even if data are invalid
* @param {FieldValuePair} fieldValuePair - Field/value to store in data context
*/
this.processFieldValuePair = function(fieldValuePair) {
var field = fieldValuePair.getFieldName();
var value = fieldValuePair.getValue();
var plainObj = {};
plainObj[field] = value;
// clean the object "to avoid any avoidable validation errors"
// [cit. aldeed - Simple-Schema author]
schema.clean(plainObj);
if(Object.keys(plainObj).length === 0)
plainObj[field] = undefined;
// update the data context
this.updateContext(plainObj);
// passing the whole dataContext but validating just the right field,
// we perform the validation we want being able to deal with dependencies
validationContext.validateOne(dataContext, field);
};
/**
* Validates the data context. If the validation is successful, it creates a new entry
* in the database to store the data context and add the MongoDB _id of the document to
* the data context. If the validation is not successful, it returns false
*/
this.create = function() {
// return a feedback about validation and database errors
var data = this.getDataContext();
// the clean method performs useful operations to avoid
// tricky validation errors (like conversion of String
// to Number when it is meaningful)
schema.clean(data);
if(validationContext.validate(data)) {
var id = collection.insert(data, function(error, result) {
if(error !== undefined)
console.log("Error on insert", error);
});
data["_id"] = id;
this.setDataContext(data);
}
else return false;
};
/**
* Update the data context with data provided by 'newData'
* @param {Object} newData - Data to store in the data context. The object structure should follow the schema definitions
*/
this.updateContext = function(newData) {
var current = dataContext;
var resetField = function(key) {
current = _.omit(current, key);
};
// apply changes to current object
for(var field in newData) {
var dotIndex = field.indexOf(".");
if(dotIndex > -1 && field[dotIndex + 2] === '.') {
// we are dealing with a field of the type 'mainField.$.customField',
// which is a field of a custom object saved in an array named mainField
var mainField = field.substring(0, dotIndex);
var index = field.substr(dotIndex + 1,1);
var customField = field.substring(dotIndex + 3);
// if newData[field] is undefined, I unset the value in data context
if(newData[field] === undefined)
current[mainField][index] = _.omit(current[mainField][index], customField);
else
// the corresponding object must already exist in the
// data context, so I just assign the new value
current[mainField][index][customField] = newData[field];
} // following if condition is too long, refactor
else if(_.contains(schema.objectKeys(), field) && Array.isArray(schema.schema(field).type()) && !Array.isArray(newData[field])) {
// If for the current field the schema expects an array of objects
// but a single object is passed, I add the object to the current array
var elems = [];
if(current[field] !== undefined)
// use .slice() to achieve deep copy
elems = current[field].slice(0);
elems.push(newData[field]);
current[field] = elems;
}
else if(newData[field] === undefined)
current = _.omit(current, field);
else current[field] = newData[field];
// check for dependencies
var deps = this.getSchema().getDefinition(field).maDependencies;
if(deps) {
_.each(deps, resetField);
}
}
// save the modified object
this.setDataContext(current);
};
/**
* Validates the data context. If the validation is successful, writes the data context to database.
* If the validation is not successful, it returns false.
* This should be called after a create() has already been called somewhere in the past.
*/
this.saveToDatabase = function() {
var current = this.getDataContext();
if(current._id === undefined)
return "It seems like a .create() has never been called!";
// up-to-date data are already in the dataContext variable, just validate
// the entire object without the _id field
var toSave = _.omit(current, '_id');
validationContext.resetValidation();
// usual clean
schema.clean(toSave);
validationContext.validate(toSave);
if(validationContext.invalidKeys().length > 0)
return false;
else {
var set = {};
var unset = {};
for(var field in _.omit(dbReplica, "_id")) {
if(toSave[field] !== undefined)
set[field] = toSave[field];
else
unset[field] = "";
}
return collection.update(current._id, {$set: set, $unset: unset}, function(error, result) {
if(error)
console.log("Error on save", error);
// something went wrong...
// TODO: add a callback that saves the datacontext in order not
// to lose changes
});
}
};
/**
* Remove the current document from database. Note: The data context is not touched, so you have to reset manually with `maWizard.reset()` if needed
*/
this.removeFromDatabase = function() {
var id = this.getDataContext()._id;
//this.reset();
return collection.remove(id, function(error, result) {
console.log("Error on remove: " + error);
console.log("Removed elements: " + result);
});
};
/**
* Returns true if the data context is different from the document in database. False otherwise.
*/
this.hasChanged = function() {
if(!this.getDataContext())
return false;
var inDatabase = collection.findOne({_id: this.getDataContext()._id});
return inDatabase && !_.isEqual(inDatabase, this.getDataContext());
};
/**
* Set the data context to undefined and resets the validation context, plus some internal cleaning
*/
this.reset = function() {
// TODO: remove orphan attachments files!!!
this.setDataContext(undefined);
validationContext.resetValidation();
activeFields = undefined;
template = undefined;
isModal = undefined;
};
/**
* Reload the data context from database, overwriting eventual changes
*/
this.discard = function() {
var current = this.getDataContext();
if(current)
this.setDataContext(loadFromDatabase(current._id));
validationContext.resetValidation();
};
/**
* Initializes the maWizard object.
* If conf.collection is not specified, an error is thrown.
* If conf.schema is not specified, it expects to find a SimpleSchema object attached to the collection with .attachSchema().
* If conf.baseRoute is not specified, the root of the website ("/") is specified as baseRoute.
* If conf.template is not specified, events on elements with data-ma-wizard-* attributes should be handled manually.
* @param {Object} conf - Configuration object
*/
this.init = function(conf) {
var contextObj;
collection = conf.collection;
if(collection === undefined)
throw "No collection defined for maWizard!";
var defs;
if(Schemas)
defs = Schemas.findOne({definition: collection._name + "_definitions"});
if(defs)
activeFields = defs.visibleFields;
else
activeFields = undefined;
if(conf.schema === undefined)
schema = collection.simpleSchema();
else
schema = conf.schema;
validationContext = schema.namedContext();
if(conf.baseRoute === undefined)
this.baseRoute = "";
else
this.baseRoute = conf.baseRoute;
contextObj = loadFromDatabase(conf.id);
this.setDataContext(contextObj);
// could be undefined, true or false (undefined by default)
isModal = conf.isModal;
template = conf.template;
// there's no way to unbind events attached to templates via Meteor APIs,
// so I keep in memory which templates I have already initialized in order
// not to add handlers more than once
if(conf.template && (initializedTemplates.indexOf(conf.template) === -1)) {
this.setStandardEventHandlers(conf.template);
initializedTemplates.push(conf.template);
}
};
/**
* Given a field, an array of the allowed values for that field is returned in the form of
* objects with label/value keys. Such values are taken from the maAllowedValues() function
* defined in the schema definition. If the maAllowedValues function is not defined, values are
* taken from the allowedValues field and in this case the label is equal to the value.
* If allowedValues is also missing, an empty array is returned.
* This function is used by the templates providing the select and multiselect components.
* @param {String} field - Name of the field of interest
*/
this.getSimpleSchemaAllowedValues = function(field) {
var maAllowedValues = maWizard.getSchemaObj(field).maAllowedValues;
var allowedValues = maWizard.getSchemaObj(field).allowedValues;
if(maAllowedValues) {
// maAllowedValues() requires a function that gets a key name
// and returns its value as parameter
var getKeyValue = function(field) {
return maWizard.getDataContext()[field];
};
return maAllowedValues(getKeyValue);
}
if(allowedValues) {
var toNormalize;
if(typeof allowedValues === 'function')
toNormalize = allowedValues();
else
toNormalize = allowedValues;
return _.map(allowedValues, function(elem) {
return {label: elem, value: label};
});
}
return [];
};
/**
* Sets standard events handlers for standard components (those with data-ma-wizard-* attributes).
* The events are managed by Meteor.
* @param {Object} templ - Template containing the standard components
*/
this.setStandardEventHandlers = function(templ) {
var backToBase = function() {
Router.go(maWizard.baseRoute);
};
Template[templ].events({
'select2-selecting select': function(evt, templ) {
var route;
// if the current option has the `value` property formatted as
// "route:routename" we want to navigate to `routename` and stop
// propagation of the event in order not to save the value to data context
var parsed = evt.val.match(/route:(.+)/);
if(parsed)
route = parsed[1];
// if `route` is undefined nothing is done so the `change` event
// will be triggered
if(route) {
$('select[data-schemafield="' +
evt.currentTarget.getAttribute("data-schemafield") +
'"]'
).select2("close");
Router.go(route);
evt.preventDefault();
}
},
'change [data-ma-wizard-control]': function(evt, templ) {
maWizard.saveHTMLElement(evt.currentTarget);
},
'click [data-ma-wizard-save]': function(evt, templ) {
if(maWizard.saveToDatabase()) {
Router.go(maWizard.baseRoute);
maWizard.reset();
}
else {
var onSaveFailure = maWizard.getOnSaveFailure();
if(onSaveFailure === undefined || (typeof onSaveFailure !== "function")) {
bootbox.alert("Cannot save to database! Check inserted data");
}
else onSaveFailure();
}
},
'click [data-ma-wizard-ok]': backToBase,
'click [data-ma-wizard-discard]': function(evt, templ) {
maWizard.discard();
backToBase();
},
'click [data-ma-wizard-back]': backToBase,
'click [data-ma-wizard-create]': function(evt, templ) {
if(maWizard.create())
Router.go(maWizard.baseRoute + "/" + maWizard.getDataContext()._id);
},
'click [data-ma-wizard-delete]': function(evt,templ) {
bootbox.confirm("Are you sure?", function(result) {
if(result && maWizard.removeFromDatabase())
Router.go(maWizard.baseRoute);
});
}
});
};
/**
* Provide a function to execute after the data context has been written to database.
* This will override the standard behaviour, which consists in navigating to the baseRoute
* specified in the parameter of init() or navigating to the root if no baseRoute has been specified
* @param {Function} callback - Function to execute after succesfully writing the data context to database
*/
this.setOnSaveFailure = function(callback) {
onSaveFailure = callback;
onSaveFailureDep.changed();
};
/**
* Reactively gets the `onSaveFailure` callback
**/
this.getOnSaveFailure = function() {
onSaveFailureDep.depend();
return onSaveFailure;
};
}
/**
* Data structure used by various methods of maWizard. It simply stores
* key/value pairs providing getters for both field name and value.
* @typedef {Object} FieldValuePair
* @property {Function} getValue - Gets the set value
* @property {Function} setValue - Sets value
* @property {Function} getFieldName - Gets field name as String
*/
function FieldValuePair(field, value) {
var _field = field;
var _value = value;
var _setValue = function(val) {
_value = val;
};
this.getValue = function() { return _value; };
this.setValue = function(val) {
return _setValue(val);
};
this.getFieldName = function() { return _field; };
}
/**************** Template helpers *****************************************
* Helpers used by maWizard templates. They are declared as global helpers
* in order to be used by all the templates.
* NOTE: all of these helpers directly reference the global maWizard object,
* so no more then one maWizard instance can be used at a time (no multiple
* wizards active together).
****************************************************************************/
UI.registerHelper('maWizardGetFieldValue', function(field) {
var current = maWizard.getDataContext();
// matches custom objects internal fields (mainFields.N.field)
// and returns an array with the needed tokens
var parsed = field.match(/(\w+)\.(\d)\.(\w+)/);
if(current) {
if(parsed) {
return current[parsed[1]][parsed[2]][parsed[3]];
}
else return current[field];
}
else
return "";
});
UI.registerHelper('maWizardGetFieldLabel', function(field) {
try {
return maWizard.getSchemaObj(field).label;
}
catch(e) {
return "";
}
});
// to use for String only, not for Number
UI.registerHelper('maWizardMaxLength', function(field) {
var schema = maWizard.getSchemaObj();
// if the field is of the type mainField.N.field we
// must replace the number N with $
var normField = field.replace(/\.\d\./, ".$.");
try {
if(schema && schema[normField]['max'])
return schema[normField]['max'];
}
catch(e) {
return -1;
}
return -1;
});
UI.registerHelper('maWizardFieldValidity', function(field) {
if(field === undefined)
return '';
var validationResult = maWizard.getValidationContext().keyIsInvalid(field);
if(validationResult)
return 'has-error';
else
return '';
});
UI.registerHelper('maWizardErrMsg', function(field) {
if(field === undefined)
return '';
var msg = maWizard.getValidationContext().keyErrorMessage(field);
return msg;
});
UI.registerHelper('maWizardOptionIsSelected', function(field) {
var current = maWizard.getDataContext();
var value = this.value;
// NOTE: current[field] could be either a String or an Array, in either case
// the indexOf() method is defined and the result is the wanted behaviour
if(current && current[field] && current[field].indexOf(value) > -1)
return "selected";
else return "";
});
UI.registerHelper('maWizardAllowedValuesFromSchema', function(field) {
return maWizard.getSimpleSchemaAllowedValues(field);
});
UI.registerHelper('maWizardIsFieldActive', function(field) {
return maWizard.isFieldActive(field);
});
/*****************************************************************************************/
// overriding the Router.go function in order not to lose changes in our wizards
// http://stackoverflow.com/questions/24367914/aborting-navigation-with-meteor-iron-router
var go = Router.go; // cache the original Router.go method
Router.go = function () {
var self = this;
var args = arguments;
function customGo() {
if(maWizard.isModal())
$('#' + maWizard.getTemplateName() + ' .modal.ma-wizard-modal')
.on('hidden.bs.modal', function() {
go.apply(self, args);
})
.modal('hide');
else go.apply(self, args);
}
if(maWizard.getDataContext() && maWizard.getDataContext()._id) {
var saveResult = maWizard.saveToDatabase();
if(typeof saveResult === 'string' || saveResult === false)
bootbox.alert("Invalid data present. Please correct them or discard changes.");
else {
customGo();
maWizard.reset();
}
}
else customGo();
};
maWizard = new maWizardConstructor();