fluid-form
Version:
Fluid form generator
345 lines (309 loc) • 7.96 kB
JavaScript
;
var _ = require('lodash');
module.exports = fluidFormController;
/*@ngInject*/
function fluidFormController($element, $scope, $parse, $timeout) {
var ngModel = $element.controller('ngModel');
$scope.fieldChanged = fieldChanged;
$scope.fieldSize = fieldSize;
$scope.fieldFocus = fieldFocus;
$scope.fieldBlur = fieldBlur;
$scope.fieldIsFocused = fieldIsFocused;
$scope.formIsValid = formIsValid;
$scope.submitForm = submitForm;
/* Dropdowns (TODO: Move to another directive) */
$scope.dropdown = {
open: openDropDown,
close: closeDropDown,
toggle: toggleDropDown,
isOpen: isOpenDropDown,
select: selectDropDown,
isSelected: isSelectedDropDown,
focus: focusDropDown,
blur: blurDropDown,
keydown: keydownDropDown
};
var activeDropDown = null;
var isAllValid = false;
var dropDownBlurCloseTimer = null;
/* Cached getters and utility functions */
var getters = [];
var getterLocals = {
set: function (value) {
return value !== undefined && value !== null && value !== '';
},
shown: function (name) {
var field = _.findWhere(clauses, { isField: true, name: name });
if (!field) {
throw new Error('Field "' + name + '" does not exist');
}
return field.visible;
},
valid: function () {
return isAllValid;
}
};
/* Data */
var model = null;
/* Schema */
var clauses = null;
/* External bindings */
ngModel.$render = modelChanged;
$scope.$watch('fluidForm', generateForm);
$scope.$watch('validator', revalidateForm);
/* Field with focus */
var focused = null;
/* Debounced change handler */
var debounceChanged = [];
var debounceTimer = null;
function generateForm() {
getters.length = 0;
var schema = $scope.schema;
clauses = !schema ? [] : schema.map(function (source) {
var clause = _.clone(source);
clause.classes = clause.classes || {};
clause.classes[clause.type] = true;
/* Echo getters */
if (clause.type === 'echo') {
clause.getter = getGetter(clause.label);
}
/* Dedupe and map condition expressions to getters */
clause.conditionGetters = clause.conditions.map(getGetter);
/* Label */
clause.getLabel = getLabel;
/* Visibility */
clause.canShow = canShow;
clause.updateVisible = updateVisible;
return clause;
function canShow() {
return _.every(clause.conditionGetters, 'value');
}
function updateVisible() {
clause.visible = clause.canShow();
clause.classes.visible = clause.visible;
clause.classes.hidden = !clause.visible;
}
function getLabel() {
var labels = $scope.labels;
var label = labels ? labels[clause.name] : '';
if (label !== null && label !== undefined) {
clause.label = String(label);
} else if (!clause.label) {
clause.label = clause.name;
}
return clause.label;
}
});
$scope.clauses = clauses;
reloadModel();
}
function modelChanged() {
model = ngModel.$viewValue;
reloadModel();
}
/* Update data-bound visibility */
function updateVisible() {
clauses.forEach(function (clause) {
clause.updateVisible();
});
}
function fieldSize(clause) {
var value = clause.value || '';
return 1 + Math.max(3, value.length ? value.length : clause.label.length);
}
function fieldFocus(clause) {
focused = clause;
}
function fieldBlur(clause) {
if (fieldIsFocused(clause)) {
focused = null;
}
revalidateForm();
}
function fieldIsFocused(clause) {
return focused === clause;
}
/* Dedupe and map expressions to getters with value cache */
function getGetter(expr) {
var getter = _.findWhere(getters, { expr: expr });
if (!getter) {
getter = {
expr: expr,
getter: $parse(expr),
value: undefined
};
getters.push(getter);
}
return getter;
}
/* Update cached values of bindings from echoes/conditions */
function updateBindings() {
getters.forEach(function (getter) {
getter.value = getter.getter(model, getterLocals);
});
updateVisible();
}
/* Reset the model */
function reloadModel() {
if (!clauses) {
return;
}
for (var i = 0; i < clauses.length; i++) {
var clause = clauses[i];
var modelValue = model[clause.name];
if (modelValue !== undefined) {
clause.value = modelValue;
}
}
updateBindings();
_(clauses)
.filter(_.property('value'))
.each(fieldChanged)
.value();
revalidateForm();
}
function revalidateForm() {
updateVisible();
if (!$scope.validator || !$scope.schema || !model) {
isAllValid = false;
return;
}
var errors = _.pairs($scope.validator({ model: model }) || {});
clauses.forEach(function (clause) {
clause.classes.invalid = false;
clause.isInvalid = false;
clause.errorMessage = null;
});
isAllValid = true;
errors.forEach(function (error) {
var name = error[0];
var msg = error[1];
var clause = _.findWhere(clauses, { name: name });
if (!clause) {
throw new Error('Validator failed: key "' + name + '" is not defined');
}
if (!clause.visible) {
return;
}
var showAsInvalid = msg && msg.length;
clause.classes.invalid = showAsInvalid;
clause.isInvalid = showAsInvalid;
clause.errorMessage = msg;
isAllValid = false;
});
updateVisible();
}
/* Queue field for update and queue a delayed update */
function fieldChanged(clause) {
debounceChanged.push(clause);
if (debounceTimer) {
$timeout.cancel(debounceTimer);
}
debounceTimer = $timeout(fieldsChanged, 100);
}
/* Update, after field value(s) changed */
function fieldsChanged(sendViewModel) {
if (debounceTimer) {
$timeout.cancel(debounceTimer);
debounceTimer = null;
}
var changes = _.unique(debounceChanged);
debounceChanged = [];
var dirty = false;
_(changes)
.unique()
.each(function (clause) {
var value = clause.value;
var isDirty = model[clause.name] !== value;
if (isDirty) {
dirty = true;
model[clause.name] = value;
}
})
.value();
if (dirty || sendViewModel) {
updateBindings();
revalidateForm();
}
}
function formIsValid() {
return isAllValid;
}
function submitForm() {
if (!isAllValid) {
throw new Error('Cannot submit form, form is invalid');
}
fieldsChanged(true);
var result = _.pick(model, function (value, key) {
return _.findWhere(clauses, { name: key }).visible;
});
ngModel.$setViewValue(model);
if ($scope.onSubmit) {
$scope.onSubmit({ model: result });
}
}
/* Dropdown stuff, TODO move to new directive like ui-bootstrap except not shit */
function openDropDown(clause) {
fieldFocus(clause);
activeDropDown = clause;
clause.classes.open = true;
}
function closeDropDown(clause) {
if (activeDropDown !== clause) {
return;
}
activeDropDown = null;
clause.classes.open = false;
fieldBlur(clause);
}
function toggleDropDown(clause) {
if (isOpenDropDown(clause)) {
closeDropDown(clause);
} else {
openDropDown(clause);
}
}
function selectDropDown(clause, value, noClose) {
clause.value = value;
fieldChanged(clause);
if (!noClose) {
closeDropDown(clause);
}
}
function isOpenDropDown(clause) {
return activeDropDown === clause;
}
function isSelectedDropDown(clause, value) {
return clause.value === value;
}
function focusDropDown(clause) {
$timeout.cancel(dropDownBlurCloseTimer);
fieldFocus(clause);
}
function blurDropDown(clause) {
$timeout.cancel(dropDownBlurCloseTimer);
dropDownBlurCloseTimer = $timeout(function () { closeDropDown(clause); }, 50);
}
function keydownDropDown(clause, event) {
var idx = clause.choices.indexOf(clause.value);
var len = clause.choices.length;
switch (event.keyCode) {
case 38:
idx += (idx === -1) ? 1 : len - 1;
break;
case 40:
idx += 1;
break;
case 32:
case 27:
case 13:
case 10:
$timeout(function () { closeDropDown(clause); }, 0);
return;
default:
return;
}
idx = idx % len;
selectDropDown(clause, clause.choices[idx], true);
}
}