public/components/editable-field/editable-field.js (168 lines of code) (raw):
/**
* Module containing directives for an editable field.
*
* @example
* <!-- Simple text field example: -->
* <wf-editable wf-editable-model="myModel">myModel value: {{ myModel || 'Not set' }}</wf-editable>
*
* <!-- Required field: -->
* <wf-editable wf-editable-model="myModel" wf-editable-required="true">myModel value: {{ myModel || 'Not set' }}</wf-editable>
*
* <!-- Call function when model updates: -->
* <wf-editable wf-editable-model="myModel" wf-editable-on-update="sendToServer(newValue)" wf-editable-required="true">myModel value: {{ myModel || 'Not set' }}</wf-editable>
*/
import angular from 'angular';
import editableFieldTemplate from './editable-field.html';
angular.module('wfEditableField', [])
.directive('wfEditable', ['$timeout', wfEditableDirectiveFactory])
.directive('wfEditableField', ['$timeout', wfEditableTextFieldDirectiveFactory]);
var KEYCODE_ESC = 27,
KEYCODE_ENTER = 13,
CLASS_EDITABLE = 'editable',
CLASS_EDITABLE_EDITMODE = 'editable--edit';
function wfEditableDirectiveFactory() {
return {
restrict: 'E',
template: editableFieldTemplate,
scope: {
modelValue: '=ngModel',
onEditableUpdate: '&wfEditableOnUpdate',
onEditableCancel: '&wfEditableOnCancel',
validateRequired: '=wfEditableRequired',
validateMinlength: '=wfEditableMinlength',
validateMaxlength: '=wfEditableMaxlength',
noCloseMode: '=wfNoCloseMode',
onEditableEditModeUpdate: '&wfEditableOnEditModeUpdate',
inputType: '=?wfEditableInputType'
},
compile: function(tElement, tAttrs) {
var nodeName,
$node,
nodeAttrs = {
'wf-editable-field': '',
'ng-model': 'modelValue',
'ng-required': 'validateRequired',
'ng-minlength': 'validateMinlength',
'ng-maxlength': 'validateMaxlength',
};
if (tAttrs.wfEditableType === 'textarea') {
nodeName = 'textarea';
} else {
nodeName = 'input';
nodeAttrs.type = tAttrs.wfEditableInputType || 'text';
}
$node = angular.element(document.createElement(nodeName));
tElement.find('wf-editable-field-target')
.replaceWith($node.attr(nodeAttrs));
return function wfEditableFieldPostLink($scope, $element, $attrs) {
$attrs.$addClass(CLASS_EDITABLE);
$scope.$on('wfEditable.changedEditMode', ($event, newValue) => {
if (newValue) { // entered edit mode
addImplicitCancelListeners();
} else {
removeImplicitCancelListeners();
}
});
/**
* Search parent elements on "element" to find "parent" up a hierarchy of "levels".
*/
function isElementChildOf(element, parent, levels) {
return element && element.parentElement === parent || levels !== 0 && isElementChildOf(element.parentElement, parent, levels - 1);
}
function checkForImplicitCancelListener(event) {
if (!isElementChildOf(event.target, $element[0], 3)) {
$scope.$broadcast('wfEditable.implicitCancel');
$scope.$apply();
}
}
/**
* Adds body listeners for an implicit cancel event - either a click
* on the body outside the control, or focus outside of the control.
*/
function addImplicitCancelListeners() {
document.body.addEventListener('mousedown', checkForImplicitCancelListener);
document.body.addEventListener('focus', checkForImplicitCancelListener, true);
}
function removeImplicitCancelListeners() {
document.body.removeEventListener('mousedown', checkForImplicitCancelListener);
document.body.removeEventListener('focus', checkForImplicitCancelListener, true);
}
};
},
transclude: true,
controllerAs: 'editableController',
controller: function wfEditableFieldController($scope, $element, $attrs) {
// one time bind of wfEditableType
$scope.editableType = $attrs.wfEditableType;
$scope.preserveWhitespace = $scope.editableType === 'textarea';
this.setEditMode = (newMode) => {
$scope.onEditableEditModeUpdate({
newMode: newMode
});
$scope.isEditMode = !!newMode;
};
this.setErrors = (errors) => $scope.editableErrors = errors;
$scope.$watch('isEditMode', (newValue, oldValue) => {
if (newValue) {
$attrs.$addClass(CLASS_EDITABLE_EDITMODE);
} else {
$attrs.$removeClass(CLASS_EDITABLE_EDITMODE);
}
// Broadcast changed edit mode when value changes on the applied scope.
if (newValue !== oldValue) {
$scope.$broadcast('wfEditable.changedEditMode', newValue, oldValue);
}
});
$scope.commit = () => {
$scope.$broadcast('wfEditable.commit');
};
$scope.cancel = () => {
$scope.$broadcast('wfEditable.cancel');
};
}
};
}
function wfEditableTextFieldDirectiveFactory($timeout) {
return {
restrict: 'A',
require: ['ngModel', '^^wfEditable'],
link: function($scope, $element, $attrs, [ ngModel, wfEditable ]) {
$attrs.$addClass('editable__text-field');
if ($scope.editableType === 'textarea') {
$attrs.$addClass('editable__text-field--textarea');
}
// resets / sets the ng-model-options (prevents default behaviour)
ngModel.$options = ngModel.$options || {};
function commit() {
var newValue = ngModel.$viewValue,
oldValue = ngModel.$modelValue;
// TODO: could check for promise from onEditableUpdate to
// display loader, before committing view value.
ngModel.$commitViewValue();
wfEditable.setErrors(ngModel.$error);
if (ngModel.$valid) {
$scope.onEditableUpdate({
newValue: newValue,
oldValue: oldValue
});
if ($scope.noCloseMode) {
// reset input
$element[0].value = '';
} else {
wfEditable.setEditMode(false);
}
ngModel.$setUntouched();
ngModel.$setPristine();
}
}
function cancel() {
$scope.onEditableCancel();
ngModel.$rollbackViewValue();
wfEditable.setErrors(ngModel.$error);
wfEditable.setEditMode(false);
}
function implicitCancel() {
if (ngModel.$viewValue === ngModel.$modelValue) {
wfEditable.setEditMode(false);
} else {
wfEditable.setErrors({ notSaved: true });
}
}
$scope.$on('wfEditable.commit', commit);
$scope.$on('wfEditable.cancel', cancel);
$scope.$on('wfEditable.implicitCancel', implicitCancel);
$scope.$on('wfEditable.changedEditMode', ($event, mode) => {
if (mode === true) {
$timeout(() => $element[0].select(), 100);
}
});
$element.on('keydown', ($event) => {
if ($event.keyCode === KEYCODE_ESC) {
$scope.$apply(cancel);
} else if ($event.keyCode === KEYCODE_ENTER) {
if ($scope.editableType === 'textarea') {
if ($event.metaKey || $event.ctrlKey || $event.altKey) {
$scope.$apply(commit);
}
} else {
$scope.$apply(commit);
}
}
});
}
};
}