UNPKG

24.4 kBJavaScriptView Raw
1
2import BunnyFormData from '../polyfills/BunnyFormData';
3
4/**
5 * BunnyJS Form component
6 * Wraps native FormData API to allow working with same form from multiple closures
7 * and adds custom methods to make form processing, including AJAX submit, file uploads and date/times, easier
8 * works only with real forms and elements in DOM
9 * Whenever new input is added or removed from DOM - Form data is updated
10 *
11 * IE11+
12 *
13 * It should:
14 * 1. RadioNodeList
15 * 1.1. Setting .value of RadioNodeList for radio buttons is allowed only to one of values from all radio buttons within this RadioNodeList,
16 * 1.2. If invalid value passed Error should be thrown
17 * 1.3. If invalid value passed old value should be returned by .value getter, new value should not be saved
18 * 1.4. Setting .value of RadioNodeList should redraw (update) radio buttons setting old unchecked and new checked
19 * 1.5. Setting .value of RadioNodeList should save new value and getting .value should return same new value
20 * 1.6. Setting .value of RadioNodeList should call 'change' event on related radio button
21 *
22 * 2. File input
23 * 2.1. .value of file input should return first File object if file uploaded by user from native UI
24 * 2.2. .value of file input should return Blob object if file was set by .value = blob
25 * 2.3. value attribute can contain a string - URL to selected object, .value should return this file's Blob
26 * 2.4. Only blob should be allowed to be assigned to setter .value
27 * 2.5. URL (including rel path) can be assigned to .value (todo)
28 * 2.6. File processing should be allowed to be delegated to a custom method, for example, to send file to cropper before storing it (todo)
29 * 2.7. Setting .value should call 'change' event on file input
30 * 2.8. After refresh if there is file uploaded by user via native UI file should be stored in .value (todo)
31 *
32 * 3. Common inputs
33 * 3.1. Setting .value to any input should call 'change' event
34 * 3.2. Setting .value to any input should redraw changes
35 * 3.3.
36 *
37 * 4. DOM mutations, new inputs added to DOM or removed from DOM
38 * 4.1. Whenever new input added to DOM inside form, it should be initiated and work as common input
39 * 4.2. Whenever input is removed from DOM inside form, it should be also removed with all events from Form collection
40 */
41export default Form = {
42
43 /*
44 properties
45 */
46
47 /**
48 * Collection of FormData
49 * @private
50 */
51 _collection: {},
52
53 /**
54 * Collection of mirrored elements
55 * See Form.mirror() for detailed description
56 */
57 _mirrorCollection: {},
58
59 _valueSetFromEvent: false,
60
61
62 //_calcMirrorCollection: {},
63
64
65 /*
66 init methods
67 */
68
69 /**
70 * Init form
71 * Must be called after DOMContentLoaded (ready)
72 *
73 * @param {string} form_id
74 *
75 * @throws Error
76 */
77 init(form_id) {
78 const form = document.forms[form_id];
79 if (form === undefined) {
80 throw new Error(`Form with ID ${form_id} not found in DOM!`);
81 }
82 if (this._collection[form_id] !== undefined) {
83 throw new Error(`Form with ID ${form_id} already initiated!`);
84 }
85 this._collection[form_id] = new BunnyFormData(form);
86 this._attachChangeAndDefaultFileEvent(form_id);
87 this._attachRadioListChangeEvent(form_id);
88 this._attachDOMChangeEvent(form_id);
89 },
90
91 isInitiated(form_id) {
92 return this._collection[form_id] !== undefined;
93 },
94
95 /**
96 * Update FormData when user changed input's value
97 * or when value changed from script
98 *
99 * Also init default value for File inputs
100 *
101 * @param {string} form_id
102 *
103 * @private
104 */
105 _attachChangeAndDefaultFileEvent(form_id) {
106 const elements = this._collection[form_id].getInputs();
107 [].forEach.call(elements, (form_control) => {
108
109 this.__attachSingleChangeEvent(form_id, form_control);
110 this.__observeSingleValueChange(form_id, form_control);
111
112 // set default file input value
113 if (form_control.type === 'file' && form_control.hasAttribute('value')) {
114 const url = form_control.getAttribute('value');
115 if (url !== '') {
116 this.setFileFromUrl(form_id, form_control.name, url);
117 }
118 }
119 });
120 },
121
122 _attachRadioListChangeEvent(form_id) {
123 const radio_lists = this._collection[form_id].getRadioLists();
124 for (let radio_group_name in radio_lists) {
125 let single_radio_list = radio_lists[radio_group_name];
126 this.__observeSingleValueChange(form_id, single_radio_list);
127 }
128 },
129
130 __attachSingleChangeEvent(form_id, form_control) {
131 form_control.addEventListener('change', (e) => {
132 if (form_control.type === 'file' && e.isTrusted) {
133 // file selected by user
134 //this._collection[form_id].set(form_control.name, form_control.files[0]);
135 this._valueSetFromEvent = true;
136 form_control.value = form_control.files[0];
137 this._valueSetFromEvent = false;
138 } else {
139 this._parseFormControl(form_id, form_control, form_control.value);
140 }
141
142 // update mirror if mirrored
143 if (this._mirrorCollection[form_id] !== undefined) {
144 if (this._mirrorCollection[form_id][form_control.name] === true) {
145 this.setMirrors(form_id, form_control.name);
146 }
147 }
148 });
149 },
150
151 // handlers for different input types
152 // with 4th argument - setter
153 // without 4th argument - getter
154 // called from .value property observer
155 _parseFormControl(form_id, form_control, value = undefined) {
156 const type = this._collection[form_id].getInputType(form_control);
157
158 console.log(type);
159
160 // check if parser for specific input type exists and call it instead
161 let method = type.toLowerCase();
162 method = method.charAt(0).toUpperCase() + method.slice(1); // upper case first char
163 method = '_parseFormControl' + method;
164
165 if (value === undefined) {
166 method = method + 'Getter';
167 }
168
169 if (this[method] !== undefined) {
170 return this[method](form_id, form_control, value);
171 } else {
172 // call default parser
173 // if input with same name exists - override
174 if (value === undefined) {
175 return this._parseFormControlDefaultGetter(form_id, form_control);
176 } else {
177 this._parseFormControlDefault(form_id, form_control, value);
178 }
179 }
180 },
181
182 _parseFormControlDefault(form_id, form_control, value) {
183 this._collection[form_id].set(form_control.name, value);
184 },
185
186 _parseFormControlDefaultGetter(form_id, form_control) {
187 return Object.getOwnPropertyDescriptor(form_control.constructor.prototype, 'value').get.call(form_control);
188 //return this._collection[form_id].get(form_control.name);
189 },
190
191 _parseFormControlRadiolist(form_id, form_control, value) {
192 let found = false;
193 const radio_list = form_control;
194 for (let k = 0; k < radio_list.length; k++) {
195 let radio_input = radio_list[k];
196 if (radio_input.value === value) {
197 this._collection[form_id].set(radio_input.name, value);
198 found = true;
199 break;
200 }
201 }
202
203 if (!found) {
204 throw new TypeError('Trying to Form.set() on radio with unexisted value="'+value+'"');
205 }
206 },
207
208 _parseFormControlCheckbox(form_id, form_control, value) {
209 const fd = this._collection[form_id];
210 fd.setCheckbox(form_control.name, value, form_control.checked);
211 },
212
213 _parseFormControlFile(form_id, form_control, value) {
214 if (value !== '' && !(value instanceof Blob) && !(value instanceof File)) {
215 throw new TypeError('Only empty string, Blob or File object is allowed to be assigned to .value property of file input using Bunny Form');
216 } else {
217 if (value.name === undefined) {
218 value.name = 'blob';
219 }
220 this._collection[form_id].set(form_control.name, value);
221 }
222 },
223
224 _parseFormControlFileGetter(form_id, form_control) {
225 // Override native file input .value logic
226 // return Blob or File object or empty string if no file set
227 return this.get(form_id, form_control.name);
228 },
229
230 __observeSingleValueChange(form_id, form_control) {
231 Object.defineProperty(form_control, 'value', {
232 configurable: true,
233 get: () => {
234 return this._parseFormControl(form_id, form_control);
235 },
236 set: (value) => {
237 console.log('setting to');
238 console.log(value);
239 console.log(form_control);
240 // call parent setter to redraw changes in UI, update checked etc.
241 if (form_control.type !== 'file') {
242 Object.getOwnPropertyDescriptor(form_control.constructor.prototype, 'value').set.call(form_control, value);
243 }
244
245
246
247 //this._parseFormControl(form_id, form_control, value);
248 if (!(this._collection[form_id].isNodeList(form_control))) {
249 if (!this._valueSetFromEvent) {
250 console.log('firing event');
251 const event = new CustomEvent('change');
252 form_control.dispatchEvent(event);
253
254 }
255 } else {
256
257 // For radio - call change event on changed input
258 for (let k = 0; k < form_control.length; k++) {
259 let radio_input = form_control[k];
260 if (radio_input.getAttribute('value') === value) {
261 if (!this._valueSetFromEvent) {
262 console.log('firing radio event');
263 const event = new CustomEvent('change');
264 radio_input.dispatchEvent(event);
265 break;
266 }
267 }
268 }
269 }
270 }
271 });
272 },
273
274
275
276
277
278
279 _initNewInput(form_id, input) {
280 this._checkInit(form_id);
281 this._collection[form_id]._initSingleInput(input);
282 this.__attachSingleChangeEvent(form_id, input);
283 this.__observeSingleValueChange(form_id, input, input.name);
284 },
285
286 _attachDOMChangeEvent(form_id) {
287 const target = document.forms[form_id];
288 const observer_config = { childList: true, subtree: true };
289 const observer = new MutationObserver( (mutations) => {
290 mutations.forEach( (mutation) => {
291 if (mutation.addedNodes.length > 0) {
292 // probably new input added, update form data
293 this.__handleAddedNodes(form_id, mutation.addedNodes);
294 } else if (mutation.removedNodes.length > 0) {
295 // probably input removed, update form data
296 this.__handleRemovedNodes(form_id, mutation.removedNodes);
297 }
298 });
299 });
300
301 observer.observe(target, observer_config);
302 },
303
304 __handleAddedNodes(form_id, added_nodes) {
305 for (let k = 0; k < added_nodes.length; k++) {
306 let node = added_nodes[k];
307 if (node.tagName === 'INPUT') {
308 this._initNewInput(form_id, node);
309 } else if (node.getElementsByTagName !== undefined) {
310 // to make sure node is not text node or any other type of node without full Element API
311 let inputs = node.getElementsByTagName('input');
312 if (inputs.length > 0) {
313 for (let k2 = 0; k2 < inputs.length; k2++) {
314 this._initNewInput(form_id, inputs[k2]);
315 }
316 }
317 }
318 }
319 },
320
321 __handleRemovedNodes(form_id, removed_nodes) {
322 for (let k = 0; k < removed_nodes.length; k++) {
323 let node = removed_nodes[k];
324 if (node.tagName === 'INPUT') {
325 let input = node;
326 this._collection[form_id].remove(input.name, input.value);
327 } else if (node.getElementsByTagName !== undefined) {
328 // to make sure node is not text node or any other type of node without full Element API
329 let inputs = node.getElementsByTagName('input');
330 if (inputs.length > 0) {
331 for (let k2 = 0; k2 < inputs.length; k2++) {
332 let input = inputs[k2];
333 this._collection[form_id].remove(input.name, input.value);
334 }
335 }
336 }
337 }
338 },
339
340 /**
341 * Init all forms in DOM
342 * Must be called after DOMContentLoaded (ready)
343 */
344 initAll() {
345 [].forEach.call(document.forms, (form) => {
346 this.init(form.id)
347 });
348 },
349
350 /**
351 * Check if form is initiated
352 *
353 * @param {string} form_id
354 *
355 * @throws Error
356 * @private
357 */
358 _checkInit(form_id) {
359 if (this._collection[form_id] === undefined) {
360 throw new Error(`Form with ID ${form_id} is not initiated! Init form with Form.init(form_id) first.`);
361 }
362 },
363
364
365 /*
366 Get and set form data methods
367 */
368
369 /**
370 * Set new value of real DOM input or virtual input
371 * Actually fires change event and values are set in _attachChangeAndDefaultFileEvent()
372 *
373 * @param {string} form_id
374 * @param {string} input_name
375 * @param {string|Blob|Object} input_value
376 */
377 set(form_id, input_name, input_value) {
378 this._checkInit(form_id);
379 const input = this._collection[form_id].getInput(input_name);
380 input.value = input_value;
381 },
382
383
384 /**
385 * Fill form data with object values. Object property name/value => form input name/value
386 * @param form_id
387 * @param data
388 */
389 fill(form_id, data) {
390 this._checkInit(form_id);
391 for (let input_name in data) {
392 if (this._collection[form_id].has(input_name)) {
393 this.set(form_id, input_name, data[input_name]);
394 }
395 }
396 },
397
398 fillOrAppend(form_id, data) {
399 this._checkInit(form_id);
400 for (let input_name in data) {
401 if (this._collection[form_id].has(input_name)) {
402 this.set(form_id, input_name, data[input_name]);
403 } else {
404 this.append(form_id, input_name, data[input_name]);
405 }
406 }
407 },
408
409 observe(form_id, data_object) {
410 let new_data_object = Object.create(data_object);
411 for (let input_name in new_data_object) {
412 Object.defineProperty(new_data_object, input_name, {
413 set: (value) => {
414 this.set(form_id, input_name, value);
415 },
416 get: () => this.get(form_id, input_name)
417 });
418 }
419 return new_data_object;
420 },
421
422 fillAndObserve(form_id, data_object) {
423 this.fill(form_id, data_object);
424 this.observe(form_id, data_object);
425 },
426
427 /**
428 * Get value of real DOM input or virtual input
429 *
430 * @param {string} form_id
431 * @param {string} input_name
432 *
433 * @returns {string|File|Blob}
434 */
435 get(form_id, input_name) {
436 this._checkInit(form_id);
437 return this._collection[form_id].get(input_name);
438 },
439
440 getObservableModel(form_id) {
441 return this.observe(form_id, this.getAll(form_id));
442 },
443
444 /**
445 * Get all form input values as key - value object
446 * @param form_id
447 * @returns {object}
448 */
449 getAll(form_id) {
450 this._checkInit(form_id);
451 /*const data = {};
452 const items = this._collection[form_id].entries();
453 for (let item of items) {
454 data[item[0]] = item[1];
455 }
456 return data;*/
457 return this._collection[form_id].getAllElements();
458 },
459
460 /**
461 * Get native FormData object
462 * For example, to submit form with custom handler
463 * @param {string} form_id
464 * @returns {FormData}
465 */
466 getFormDataObject(form_id) {
467 this._checkInit(form_id);
468 return this._collection[form_id].buildFormDataObject();
469 },
470
471 getInput(form_id, input_name) {
472 return this._collection[form_id].getInput(input_name);
473 },
474
475
476 /*
477 virtual checkbox, item list, tag list, etc methods
478 */
479 append(form_id, array_name, value) {
480 this._checkInit(form_id);
481 const formData = this._collection[form_id];
482 formData.append(array_name, value);
483 },
484
485 remove(form_id, array_name, value = undefined) {
486 this._checkInit(form_id);
487 /*const formData = this._collection[form_id];
488 formData.delete(array_name);
489 const collection = formData.getAll(array_name);
490 collection.forEach( (item) => {
491 if (item !== value) {
492 formData.append(array_name, item);
493 }
494 });*/
495 this._collection[form_id].remove(array_name, value);
496 },
497
498
499 /*
500 binding (mirror) methods
501 */
502
503 /**
504 * Mirrors real DOM input's value with any DOM element (two-way data binding)
505 * All DOM elements with attribute data-mirror="form_id.input_name" are always updated when input value changed
506 * @param {string} form_id
507 * @param {string} input_name
508 */
509 mirror(form_id, input_name) {
510 this._checkInit(form_id);
511 const input = this._collection[form_id].getInput(input_name);
512 if (!(input instanceof HTMLInputElement)) {
513 // make sure it is normal input and not RadioNodeList or other interfaces which don't have addEventListener
514 throw new Error('Cannot mirror radio buttons or checkboxes.')
515 }
516 if (this._mirrorCollection[form_id] === undefined) {
517 this._mirrorCollection[form_id] = {};
518 }
519 this._mirrorCollection[form_id][input_name] = true;
520
521 //const input = document.forms[form_id].elements[input_name];
522 this.setMirrors(form_id, input_name);
523 input.addEventListener('change', () => {
524 this.setMirrors(form_id, input_name);
525 });
526 },
527
528 /**
529 * Mirrors all inputs of form
530 * Does not mirror radio buttons and checkboxes
531 * See Form.mirror() for detailed description
532 * @param form_id
533 */
534 mirrorAll(form_id) {
535 this._checkInit(form_id);
536 const inputs = this._collection[form_id].getInputs();
537 [].forEach.call(inputs, (input) => {
538 if (input instanceof HTMLInputElement && input.type !== 'checkbox' && input.type !== 'radio') {
539 // make sure it is normal input and not RadioNodeList or other interfaces which don't have addEventListener
540 this.mirror(form_id, input.name);
541 }
542 });
543 },
544
545 getMirrors(form_id, input_name) {
546 this._checkInit(form_id);
547 return document.querySelectorAll(`[data-mirror="${form_id}.${input_name}"]`);
548 },
549
550 setMirrors(form_id, input_name) {
551 this._checkInit(form_id);
552 const mirrors = this.getMirrors(form_id, input_name);
553 const input = this._collection[form_id].getInput(input_name);
554 //const input = document.forms[form_id].elements[input_name];
555 [].forEach.call(mirrors, (mirror) => {
556 if (mirror.tagName === 'IMG') {
557 let data = this.get(form_id, input_name);
558 if (data === '') {
559 mirror.src = '';
560 } else if (data.size !== 0) {
561 mirror.src = URL.createObjectURL(this.get(form_id, input_name));
562 }
563 } else {
564 mirror.textContent = input.value;
565 }
566 });
567 },
568
569
570 /*
571 Calc methods
572 */
573 /*_getCalcMirrors(form_id) {
574 this._checkInit(form_id);
575 return document.querySelectorAll(`[data-mirror="${form_id}"]`);
576 },
577
578 _getCalcMirrorFunction(calc_mirror_el) {
579 return calc_mirror_el.getAttribute('data-mirror-function');
580 },
581
582 _calcMirror(form_id, calc_mirror, calc_mirror_function) {
583 // parse function
584 const input_names = calc_mirror_function.split('*');
585 console.log(input_names);
586 // get arguments (inputs)
587 const input1 = document.forms[form_id].elements[input_names[0]];
588 const input2 = document.forms[form_id].elements[input_names[1]];
589
590 const value1 = (input1.value === '') ? 0 : input1.value;
591 const value2 = (input2.value === '') ? 0 : input2.value;
592
593 // update collection
594 if (this._calcMirrorCollection[form_id] === undefined) {
595 this._calcMirrorCollection[form_id] = {};
596 }
597 if (this._calcMirrorCollection[form_id][input1.name] === undefined) {
598 this._calcMirrorCollection[form_id][input1.name] = {}
599 }
600 if (this._calcMirrorCollection[form_id][input2.name] === undefined) {
601 this._calcMirrorCollection[form_id][input2.name] = {}
602 }
603 this._calcMirrorCollection[form_id][input1.name][input2.name] = calc_mirror;
604 this._calcMirrorCollection[form_id][input2.name][input1.name] = calc_mirror;
605
606 // set initial value
607 calc_mirror.textContent = value1 * value2;
608
609 // set new value when input value changed
610 input1.addEventListener('change', () => {
611 calc_mirror.textContent = input1.value * document.forms[form_id].elements[input2.name].value;
612 });
613 input2.addEventListener('change', () => {
614 calc_mirror.textContent = input2.value * document.forms[form_id].elements[input1.name].value;
615 });
616 },
617
618 calcMirrorAll(form_id) {
619 this._checkInit(form_id);
620 const calc_mirrors = this._getCalcMirrors(form_id);
621 for(let calc_mirror of calc_mirrors) {
622 let f = this._getCalcMirrorFunction(calc_mirror);
623 if (f === undefined) {
624 console.trace();
625 throw new Error('Calc mirror element with attribute data-mirror does not have attribute data-mirror-function')
626 } else {
627 this._calcMirror(form_id, calc_mirror, f);
628 }
629 }
630 },*/
631
632 /*
633 file methods
634 */
635 setFileFromUrl(form_id, input_name, url) {
636 var request = new XMLHttpRequest();
637 const p = new Promise( (success, fail) => {
638 request.onload = () => {
639 if (request.status === 200) {
640 const blob = request.response;
641 this.set(form_id, input_name, blob);
642 success(blob);
643 } else {
644 fail(request);
645 }
646 };
647 });
648
649 request.open('GET', url, true);
650 request.responseType = 'blob';
651 request.send();
652 return p;
653 },
654
655
656 /*
657 submit methods
658 */
659
660 submit(form_id, url = null, method = 'POST', headers = {'X-Requested-With': 'XMLHttpRequest'}) {
661 this._checkInit(form_id);
662 const request = new XMLHttpRequest();
663 if (url === null) {
664 if (document.forms[form_id].hasAttribute('action')) {
665 url = document.forms[form_id].getAttribute('action');
666 } else {
667 //throw new Error('Form.submit() is missing 2nd URL argument');
668 url = '';
669 }
670 }
671 request.open(method, url);
672 const p = new Promise( (success, fail) => {
673 request.onload = () => {
674 if (request.status === 200) {
675 success(request.responseText);
676 } else {
677 fail(request);
678 }
679 };
680 });
681 for (let header in headers) {
682 request.setRequestHeader(header, headers[header]);
683 }
684 this._collection[form_id].set('categories', [2, 3]);
685 request.send(this.getFormDataObject(form_id));
686 return p;
687 }
688
689};