UNPKG

28.6 kBJavaScriptView Raw
1
2import {BunnyFile} from "./file/file";
3import {BunnyImage} from "./file/image";
4import {Ajax} from "./bunny.ajax";
5import {BunnyElement} from "./BunnyElement";
6
7export const ValidationConfig = {
8
9 // div/node class name selector which contains one label, one input, one help text etc.
10 classInputGroup: 'form-group',
11 // class to be applied on input group node if it has invalid input
12 classInputGroupError: 'has-danger',
13 // class to be applied on input group node if it input passed validation (is valid)
14 classInputGroupSuccess: 'has-success',
15
16 // label to pick textContent from to insert field name into error message
17 classLabel: 'form-control-label',
18
19 // error message tag name
20 tagNameError: 'small',
21 // error message class
22 classError: 'text-help',
23
24 // query selector to search inputs within input groups to validate
25 selectorInput: '[name]'
26
27};
28
29/**
30 * Bunny Form Validation default Translations (EN)
31 *
32 * object key = validator method name
33 * may use additional parameters in rejected (invalid) Promise
34 * each invalid input will receive {label} parameter anyway
35 * ajax error message should be received from server via JSON response in "message" key
36 */
37export const ValidationLang = {
38
39 required: "'{label}' is required",
40 email: "'{label}' should be a valid e-mail address",
41 url: "{label} should be a valid website URL",
42 tel: "'{label}' is not a valid telephone number",
43 maxLength: "'{label}' length must be < '{maxLength}'",
44 minLength: "'{label}' length must be > '{minLength}'",
45 maxFileSize: "Max file size must be < {maxFileSize}MB, uploaded {fileSize}MB",
46 image: "'{label}' should be an image (JPG or PNG)",
47 minImageDimensions: "'{label}' must be > {minWidth}x{minHeight}, uploaded {width}x{height}",
48 maxImageDimensions: "'{label}' must be < {maxWidth}x{maxHeight}, uploaded {width}x{height}",
49 requiredFromList: "Select '{label}' from list",
50 confirmation: "'{label}' is not equal to '{originalLabel}'",
51 minOptions: "Please select at least {minOptionsCount} options"
52
53};
54
55/**
56 * Bunny Validation helper - get file to validate
57 * @param {HTMLInputElement} input
58 * @returns {File|Blob|boolean} - If no file uploaded - returns false
59 * @private
60 */
61const _bn_getFile = (input) => {
62 // if there is custom file upload logic, for example, images are resized client-side
63 // generated Blobs should be assigned to fileInput._file
64 // and can be sent via ajax with FormData
65
66 // if file was deleted, custom field can be set to an empty string
67
68 // Bunny Validation detects if there is custom Blob assigned to file input
69 // and uses this file for validation instead of original read-only input.files[]
70 if (input._file !== undefined && input._file !== '') {
71 if (input._file instanceof Blob === false) {
72 console.error(`Custom file for input ${input.name} is not an instance of Blob`);
73 return false;
74 }
75 return input._file;
76 }
77 return input.files[0] || false;
78};
79
80/**
81 * Bunny Form Validation Validators
82 *
83 * Each validator is a separate method
84 * Each validator return Promise
85 * Each Promise has valid and invalid callbacks
86 * Invalid callback may contain argument - string of error message or object of additional params for lang error message
87 */
88export const ValidationValidators = {
89
90 required(input){
91 return new Promise((valid, invalid) => {
92 if (input.hasAttribute('required')) {
93 // input is required, check value
94 if (
95 input.getAttribute('type') !== 'file' && input.value === ''
96 || ((input.type === 'radio' || input.type === 'checkbox') && input.validity.valueMissing)
97 || input.getAttribute('type') === 'file' && _bn_getFile(input) === false) {
98 // input is empty or file is not uploaded
99 invalid();
100 } else {
101 valid();
102 }
103 } else {
104 valid();
105 }
106 });
107 },
108
109 email(input) {
110 return new Promise((valid, invalid) => {
111 if (input.value.length > 0 && input.getAttribute('type') === 'email') {
112 // input is email, parse string to match email regexp
113 const Regex = /[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/i;
114 if (Regex.test(input.value)) {
115 valid();
116 } else {
117 invalid();
118 }
119 } else {
120 valid();
121 }
122 });
123 },
124
125 url(input){
126 return new Promise((valid, invalid) => {
127 if (input.value.length > 0 && input.getAttribute('type') === 'url') {
128 // input is URL, parse string to match website URL regexp
129 const Regex = /(^|\s)((https?:\/\/)?[\w-]+(\.[\w-]+)+\.?(:\d+)?(\/\S*)?)/gi;
130 if (Regex.test(input.value)) {
131 valid();
132 } else {
133 invalid();
134 }
135 } else {
136 valid();
137 }
138 });
139 },
140
141 tel(input){
142 return new Promise((valid, invalid) => {
143 if (input.value.length > 0 && input.getAttribute('type') === 'tel') {
144 // input is tel, parse string to match tel regexp
145 const Regex = /^[0-9\-\+\(\)\#\ \*]{6,20}$/;
146 if (Regex.test(input.value)) {
147 valid();
148 } else {
149 invalid();
150 }
151 } else {
152 valid();
153 }
154 });
155 },
156
157 maxLength(input) {
158 return new Promise((valid, invalid) => {
159 if (input.getAttribute('maxlength') !== null && input.value.length > input.getAttribute('maxlength')) {
160 invalid({maxLength: input.getAttribute('maxlength')});
161 } else {
162 valid();
163 }
164 });
165 },
166
167 minLength(input) {
168 return new Promise((valid, invalid) => {
169 if (input.getAttribute('minlength') !== null && input.value.length < input.getAttribute('minlength')) {
170 invalid({minLength: input.getAttribute('minlength')});
171 } else {
172 valid();
173 }
174 });
175 },
176
177 maxFileSize(input) {
178 return new Promise((valid, invalid) => {
179 if (
180 input.getAttribute('type') === 'file'
181 && input.hasAttribute('maxfilesize')
182 && _bn_getFile(input) !== false
183 ) {
184 const maxFileSize = parseFloat(input.getAttribute('maxfilesize')); // in MB
185 const fileSize = (_bn_getFile(input).size / 1000000).toFixed(2); // in MB
186 if (fileSize <= maxFileSize) {
187 valid(input);
188 } else {
189 invalid({maxFileSize, fileSize});
190 }
191 } else {
192 valid(input);
193 }
194 });
195 },
196
197 // if file input has "accept" attribute and it contains "image",
198 // then check if uploaded file is a JPG or PNG
199 image(input) {
200 return new Promise((valid, invalid) => {
201 if (
202 input.getAttribute('type') === 'file'
203 && input.getAttribute('accept').indexOf('image') > -1
204 && _bn_getFile(input) !== false
205 ) {
206 BunnyFile.getSignature(_bn_getFile(input)).then(signature => {
207 if (BunnyFile.isJpeg(signature) || BunnyFile.isPng(signature)) {
208 valid();
209 } else {
210 invalid({signature});
211 }
212 }).catch(e => {
213 invalid(e);
214 });
215 } else {
216 valid();
217 }
218 });
219 },
220
221 minImageDimensions(input) {
222 return new Promise((valid, invalid) => {
223 if (input.hasAttribute('mindimensions') && _bn_getFile(input) !== false) {
224 const [minWidth, minHeight] = input.getAttribute('mindimensions').split('x');
225 BunnyImage.getImageByBlob(_bn_getFile(input)).then(img => {
226 const width = BunnyImage.getImageWidth(img);
227 const height = BunnyImage.getImageHeight(img);
228 if (width < minWidth || height < minHeight) {
229 invalid({width: width, height: height, minWidth, minHeight});
230 } else {
231 valid();
232 }
233 }).catch(e => {
234 invalid(e);
235 });
236 } else {
237 valid();
238 }
239 });
240 },
241
242 maxImageDimensions(input) {
243 return new Promise((valid, invalid) => {
244 if (input.hasAttribute('maxdimensions') && _bn_getFile(input) !== false) {
245 const [maxWidth, maxHeight] = input.getAttribute('maxdimensions').split('x');
246 BunnyImage.getImageByBlob(_bn_getFile(input)).then(img => {
247 const width = BunnyImage.getImageWidth(img);
248 const height = BunnyImage.getImageHeight(img);
249 if (width > maxWidth || height > maxHeight) {
250 invalid({width: width, height: height, maxWidth, maxHeight});
251 } else {
252 valid();
253 }
254 }).catch(e => {
255 invalid(e);
256 });
257 } else {
258 valid();
259 }
260 });
261 },
262
263 requiredFromList(input) {
264 return new Promise((valid, invalid) => {
265 let id;
266 if (input.hasAttribute('requiredfromlist')) {
267 id = input.getAttribute('requiredfromlist')
268 } else {
269 id = input.name + '_id';
270 }
271 const srcInput = document.getElementById(id);
272 if (srcInput) {
273 if (srcInput.value.length > 0) {
274 valid();
275 } else {
276 invalid();
277 }
278 } else {
279 valid();
280 }
281 });
282 },
283
284 minOptions(input) {
285 return new Promise((valid, invalid) => {
286 if (input.hasAttribute('minoptions')) {
287 const minOptionsCount = parseInt(input.getAttribute('minoptions'));
288 const inputGroup = ValidationUI.getInputGroup(input);
289 const hiddenInputs = inputGroup.getElementsByTagName('input');
290 let selectedOptionsCount = 0;
291 [].forEach.call(hiddenInputs, hiddenInput => {
292 if (hiddenInput !== input && hiddenInput.value !== '') {
293 selectedOptionsCount++
294 }
295 });
296 if (selectedOptionsCount < minOptionsCount) {
297 invalid({minOptionsCount});
298 } else {
299 valid();
300 }
301 } else {
302 valid();
303 }
304 })
305 },
306
307 confirmation(input) {
308 return new Promise((valid, invalid) => {
309 if (input.name.indexOf('_confirmation') > -1) {
310 const originalInputId = input.name.substr(0, input.name.length - 13);
311 const originalInput = document.getElementById(originalInputId);
312 if (originalInput.value == input.value) {
313 valid();
314 } else {
315 invalid({originalLabel: ValidationUI.getLabel(ValidationUI.getInputGroup(originalInput)).textContent});
316 }
317 } else {
318 valid();
319 }
320 });
321 },
322
323 // if input's value is not empty and input has attribute "data-ajax" which should contain ajax URL with {value}
324 // which will be replaced by URI encoded input.value
325 // then ajax request will be made to validate input
326 //
327 // ajax request should return JSON response
328 // if JSON response has "message" key and message key is not empty string - input is invalid
329 // server should return validation error message, it may contain {label}
330 // Does not works with file inputs
331 ajax(input) {
332 return new Promise((valid, invalid) => {
333 if (input.dataset.ajax !== undefined && input.value.length > 0) {
334 const url = input.dataset.ajax.replace('{value}', encodeURIComponent(input.value))
335 Ajax.get(url, data => {
336 data = JSON.parse(data);
337 if (data.message !== undefined && data.message !== '') {
338 invalid(data.message);
339 } else {
340 valid();
341 }
342 }, () => {
343 invalid('Ajax error');
344 });
345 } else {
346 valid();
347 }
348 });
349 }
350
351};
352
353/**
354 * @package BunnyJS
355 * @component Validation
356 *
357 * Base Object to work with DOM, creates error messages
358 * and searches for inputs within "input groups" and related elements
359 * Each input should be wrapped around an "input group" element
360 * Each "input group" should contain one input, may contain one label
361 * Multiple inputs within same "Input group" should not be used for validation
362 * <fieldset> is recommended to be used to wrap more then one input
363 */
364export const ValidationUI = {
365
366 config: ValidationConfig,
367
368 /* ************************************************************************
369 * ERROR MESSAGE
370 */
371
372 /**
373 * DOM algorithm - where to insert error node/message
374 *
375 * @param {HTMLElement} inputGroup
376 * @param {HTMLElement} errorNode
377 */
378 insertErrorNode(inputGroup, errorNode) {
379 inputGroup.appendChild(errorNode);
380 },
381
382
383
384 /**
385 * DOM algorithm - where to add/remove error class
386 *
387 * @param {HTMLElement} inputGroup
388 */
389 toggleErrorClass(inputGroup) {
390 inputGroup.classList.toggle(this.config.classInputGroupError);
391 },
392
393
394
395 /**
396 * Create DOM element for error message
397 *
398 * @returns {HTMLElement}
399 */
400 createErrorNode() {
401 const el = document.createElement(this.config.tagNameError);
402 el.classList.add(this.config.classError);
403 return el;
404 },
405
406
407
408 /**
409 * Find error message node within input group or false if not found
410 *
411 * @param {HTMLElement} inputGroup
412 *
413 * @returns {HTMLElement|boolean}
414 */
415 getErrorNode(inputGroup) {
416 return inputGroup.getElementsByClassName(this.config.classError)[0] || false;
417 },
418
419
420
421 /**
422 * Removes error node and class from input group if exists
423 *
424 * @param {HTMLElement} inputGroup
425 */
426 removeErrorNode(inputGroup) {
427 const el = this.getErrorNode(inputGroup);
428 if (el) {
429 el.parentNode.removeChild(el);
430 this.toggleErrorClass(inputGroup);
431 }
432 },
433
434
435
436 /**
437 * Removes all error node and class from input group if exists within section
438 *
439 * @param {HTMLElement} section
440 */
441 removeErrorNodesFromSection(section) {
442 [].forEach.call(this.getInputGroupsInSection(section), inputGroup => {
443 this.removeErrorNode(inputGroup);
444 });
445 },
446
447
448
449 /**
450 * Creates and includes into DOM error node or updates error message
451 *
452 * @param {HTMLElement} inputGroup
453 * @param {String} message
454 */
455 setErrorMessage(inputGroup, message) {
456 let errorNode = this.getErrorNode(inputGroup);
457 if (errorNode === false) {
458 // container for error message doesn't exists, create new
459 errorNode = this.createErrorNode();
460 this.toggleErrorClass(inputGroup);
461 this.insertErrorNode(inputGroup, errorNode)
462 }
463 // set or update error message
464 errorNode.textContent = message;
465 },
466
467
468
469 /**
470 * Marks input as valid
471 *
472 * @param {HTMLElement} inputGroup
473 */
474 setInputValid(inputGroup) {
475 inputGroup.classList.add(this.config.classInputGroupSuccess);
476 },
477
478
479
480 /* ************************************************************************
481 * SEARCH DOM
482 */
483
484 /**
485 * DOM Algorithm - which inputs should be selected for validation
486 *
487 * @param {HTMLElement} inputGroup
488 *
489 * @returns {HTMLElement|boolean}
490 */
491 getInput(inputGroup) {
492 return inputGroup.querySelector(this.config.selectorInput) || false;
493 },
494
495
496
497 /**
498 * Find closest parent inputGroup element by Input element
499 *
500 * @param {HTMLElement} input
501 *
502 * @returns {HTMLElement}
503 */
504 getInputGroup(input) {
505 let el = input;
506 while ((el = el.parentNode) && !el.classList.contains(this.config.classInputGroup));
507 return el;
508 },
509
510
511
512 /**
513 * Find inputs in section
514 *
515 * @meta if second argument true - return object with meta information to use during promise resolving
516 *
517 * @param {HTMLElement} node
518 * @param {boolean} resolving = false
519 *
520 * @returns {Array|Object}
521 */
522 getInputsInSection(node, resolving = false) {
523 const inputGroups = this.getInputGroupsInSection(node);
524 let inputs;
525 if (resolving) {
526 inputs = {
527 inputs: {},
528 invalidInputs: {},
529 length: 0,
530 unresolvedLength: 0,
531 invalidLength: 0
532 };
533 } else {
534 inputs = [];
535 }
536 for (let k = 0; k < inputGroups.length; k++) {
537 const input = this.getInput(inputGroups[k]);
538 if (input === false) {
539 console.error(inputGroups[k]);
540 throw new Error('Bunny Validation: Input group has no input');
541 }
542 if (resolving) {
543 inputs.inputs[k] = {
544 input: input,
545 isValid: null
546 };
547 inputs.length++;
548 inputs.unresolvedLength++;
549 } else {
550 inputs.push(input);
551 }
552 }
553 return inputs;
554 },
555
556
557
558 /**
559 * Find label associated with input within input group
560 *
561 * @param {HTMLElement} inputGroup
562 *
563 * @returns {HTMLElement|boolean}
564 */
565 getLabel(inputGroup) {
566 return inputGroup.getElementsByTagName('label')[0] || false;
567 },
568
569
570
571 /**
572 * Find all input groups within section
573 *
574 * @param {HTMLElement} node
575 *
576 * @returns {HTMLCollection}
577 */
578 getInputGroupsInSection(node) {
579 return node.getElementsByClassName(this.config.classInputGroup);
580 }
581
582};
583
584export const Validation = {
585
586 validators: ValidationValidators,
587 lang: ValidationLang,
588 ui: ValidationUI,
589
590 init(form, inline = false) {
591 // disable browser built-in validation
592 form.setAttribute('novalidate', '');
593
594 form.addEventListener('submit', e => {
595 e.preventDefault();
596 const submitBtns = form.querySelectorAll('[type="submit"]');
597 [].forEach.call(submitBtns, submitBtn => {
598 submitBtn.disabled = true;
599 });
600 this.validateSection(form).then(result => {
601 [].forEach.call(submitBtns, submitBtn => {
602 submitBtn.disabled = false;
603 });
604 if (result === true) {
605 form.submit();
606 } else {
607 this.focusInput(result[0]);
608 }
609 })
610 });
611
612 if (inline) {
613 this.initInline(form);
614 }
615 },
616
617 initInline(node) {
618 const inputs = this.ui.getInputsInSection(node);
619 inputs.forEach(input => {
620 input.addEventListener('change', () => {
621 this.checkInput(input).catch(e => {});
622 })
623 })
624 },
625
626 validateSection(node) {
627 if (node.__bunny_validation_state === undefined) {
628 node.__bunny_validation_state = true;
629 } else {
630 throw new Error('Bunny Validation: validation already in progress.');
631 }
632 return new Promise(resolve => {
633 const resolvingInputs = this.ui.getInputsInSection(node, true);
634 if (resolvingInputs.length === 0) {
635 // nothing to validate, end
636 this._endSectionValidation(node, resolvingInputs, resolve);
637 } else {
638 // run async validation for each input
639 // when last async validation will be completed, call validSection or invalidSection
640 let promises = [];
641 for(let i = 0; i < resolvingInputs.length; i++) {
642 const input = resolvingInputs.inputs[i].input;
643
644 this.checkInput(input).then(() => {
645 this._addValidInput(resolvingInputs, input);
646 if (resolvingInputs.unresolvedLength === 0) {
647 this._endSectionValidation(node, resolvingInputs, resolve);
648 }
649 }).catch(errorMessage => {
650 this._addInvalidInput(resolvingInputs, input);
651 if (resolvingInputs.unresolvedLength === 0) {
652 this._endSectionValidation(node, resolvingInputs, resolve);
653 }
654 });
655 }
656
657 // if there are not resolved promises after 3s, terminate validation, mark pending inputs as invalid
658 setTimeout(() => {
659 if (resolvingInputs.unresolvedLength > 0) {
660 let unresolvedInputs = this._getUnresolvedInputs(resolvingInputs);
661 for (let i = 0; i < unresolvedInputs.length; i++) {
662 const input = unresolvedInputs[i];
663 const inputGroup = this.ui.getInputGroup(input);
664 this._addInvalidInput(resolvingInputs, input);
665 this.ui.setErrorMessage(inputGroup, 'Validation terminated after 3s');
666 if (resolvingInputs.unresolvedLength === 0) {
667 this._endSectionValidation(node, resolvingInputs, resolve);
668 }
669 }
670 }
671 }, 3000);
672 }
673 });
674 },
675
676 focusInput(input, delay = 500, offset = -50) {
677 BunnyElement.scrollTo(input, delay, offset);
678 input.focus();
679 if (
680 input.offsetParent !== null
681 && input.setSelectionRange !== undefined
682 && ['text', 'search', 'url', 'tel', 'password'].indexOf(input.type) !== -1
683 && typeof input.setSelectionRange === 'function'
684 ) {
685 input.setSelectionRange(input.value.length, input.value.length);
686 }
687 },
688
689 checkInput(input) {
690 return new Promise((valid, invalid) => {
691 this._checkInput(input, 0, valid, invalid);
692 });
693
694 },
695
696 _addValidInput(resolvingInputs, input) {
697 resolvingInputs.unresolvedLength--;
698 for (let k in resolvingInputs.inputs) {
699 if (input === resolvingInputs.inputs[k].input) {
700 resolvingInputs.inputs[k].isValid = true;
701 break;
702 }
703 }
704 },
705
706 _addInvalidInput(resolvingInputs, input) {
707 resolvingInputs.unresolvedLength--;
708 resolvingInputs.invalidLength++;
709 for (let k in resolvingInputs.inputs) {
710 if (input === resolvingInputs.inputs[k].input) {
711 resolvingInputs.inputs[k].isValid = false;
712 resolvingInputs.invalidInputs[k] = input;
713 break;
714 }
715 }
716 },
717
718 _getUnresolvedInputs(resolvingInputs) {
719 let unresolvedInputs = [];
720 for (let k in resolvingInputs.inputs) {
721 if (!resolvingInputs.inputs[k].isValid) {
722 unresolvedInputs.push(resolvingInputs.inputs[k].input);
723 }
724 }
725 return unresolvedInputs;
726 },
727
728 _endSectionValidation(node, resolvingInputs, resolve) {
729 delete node.__bunny_validation_state;
730
731 if (resolvingInputs.invalidLength === 0) {
732 // form or section is valid
733 return resolve(true);
734 } else {
735 let invalidInputs = [];
736 for(let k in resolvingInputs.invalidInputs) {
737 invalidInputs.push(resolvingInputs.invalidInputs[k]);
738 }
739 // form or section has invalid inputs
740 return resolve(invalidInputs);
741 }
742 },
743
744 _checkInput(input, index, valid, invalid) {
745 const validators = Object.keys(this.validators);
746 const currentValidatorName = validators[index];
747 const currentValidator = this.validators[currentValidatorName];
748 currentValidator(input).then(() => {
749 index++;
750 if (validators[index] !== undefined) {
751 this._checkInput(input, index, valid, invalid)
752 } else {
753 const inputGroup = this.ui.getInputGroup(input);
754 // if has error message, remove it
755 this.ui.removeErrorNode(inputGroup);
756
757 if (input.form && input.form.hasAttribute('showvalid')) {
758 // mark input as valid
759 this.ui.setInputValid(inputGroup);
760 }
761
762 valid();
763 }
764 }).catch(data => {
765 // Check if Data is system Exception
766 if (data !== undefined && data.message !== undefined) {
767 throw data;
768 }
769
770 // get input group and label
771 const inputGroup = this.ui.getInputGroup(input);
772 const label = this.ui.getLabel(inputGroup);
773
774 // get error message
775 const errorMessage = this._getErrorMessage(currentValidatorName, input, label, data);
776
777 // set error message
778 this.ui.setErrorMessage(inputGroup, errorMessage);
779 invalid(errorMessage);
780 });
781 },
782
783 _getErrorMessage(validatorName, input, label, data) {
784 let message = '';
785 if (typeof data === 'string') {
786 // if validator returned string (from ajax for example), use it
787 message = data;
788 } else {
789 if (this.lang[validatorName] === undefined) {
790 throw new Error('Bunny Validation: Lang message not found for validator: ' + validatorName);
791 }
792 message = this.lang[validatorName];
793 }
794
795 // replace params in error message
796 message = message.replace('{label}', this._getInputTitle(input, label));
797
798 for (let paramName in data) {
799 message = message.replace('{' + paramName + '}', data[paramName]);
800 }
801 return message;
802 },
803
804 _getInputTitle(input, label) {
805 if (label !== false) {
806 return label.textContent;
807 } else if (input.placeholder && input.placeholder !== '') {
808 return input.placeholder;
809 } else if (input.getAttribute('aria-label') && input.getAttribute('aria-label') !== '') {
810 return input.getAttribute('aria-label');
811 } else if (input.name && input.name !== '') {
812 return input.name;
813 } else {
814 return '';
815 }
816 }
817
818};
819
820document.addEventListener('DOMContentLoaded', () => {
821 [].forEach.call(document.forms, form => {
822 if (form.getAttribute('validator') === 'bunny') {
823 const inline = form.hasAttribute('validator-inline');
824 Validation.init(form, inline);
825 }
826 });
827});