[IMP] misc CSS improvements, mainly editable lists
[odoo/odoo.git] / addons / web / static / src / js / view_list_editable.js
1 /**
2  * handles editability case for lists, because it depends on form and forms already depends on lists it had to be split out
3  * @namespace
4  */
5 openerp.web.list_editable = function (instance) {
6     // editability status of list rows
7     instance.web.ListView.prototype.defaults.editable = null;
8
9     // TODO: not sure second @lends on existing item is correct, to check
10     instance.web.ListView.include(/** @lends instance.web.ListView# */{
11         init: function () {
12             var self = this;
13             this._super.apply(this, arguments);
14
15             this._force_editability = null;
16             this._context_editable = false;
17             this.editor = this.make_editor();
18             // Stores records of {field, cell}, allows for re-rendering fields
19             // depending on cell state during and after resize events
20             this.fields_for_resize = [];
21             instance.web.bus.on('resize', this, this.resize_fields);
22
23             $(this.groups).bind({
24                 'edit': function (e, id, dataset) {
25                     self.do_edit(dataset.index, id, dataset);
26                 },
27                 'saved': function () {
28                     if (self.groups.get_selection().length) {
29                         return;
30                     }
31                     self.configure_pager(self.dataset);
32                     self.compute_aggregates();
33                 }
34             });
35
36             this.records.bind('remove', function () {
37                 if (self.editor.is_editing()) {
38                     self.cancel_edition();
39                 }
40             });
41
42             this.on('edit:before', this, function (event) {
43                 if (!self.editable() || self.editor.is_editing()) {
44                     event.cancel = true;
45                 }
46             });
47             this.on('edit:after', this, function () {
48                 self.$element.add(self.$buttons).addClass('oe_editing');
49             });
50             this.on('save:after cancel:after', this, function () {
51                 self.$element.add(self.$buttons).removeClass('oe_editing');
52             });
53         },
54         destroy: function () {
55             instance.web.bus.off('resize', this, this.resize_fields);
56             this._super();
57         },
58         /**
59          * Handles the activation of a record in editable mode (making a record
60          * editable), called *after* the record has become editable.
61          *
62          * The default behavior is to setup the listview's dataset to match
63          * whatever dataset was provided by the editing List
64          *
65          * @param {Number} index index of the record in the dataset
66          * @param {Object} id identifier of the record being edited
67          * @param {instance.web.DataSet} dataset dataset in which the record is available
68          */
69         do_edit: function (index, id, dataset) {
70             _.extend(this.dataset, dataset);
71         },
72         editable: function () {
73             return this.fields_view.arch.attrs.editable
74                 || this._context_editable
75                 || this.options.editable;
76         },
77         /**
78          * Replace do_search to handle editability process
79          */
80         do_search: function(domain, context, group_by) {
81             this._context_editable = !!context.set_editable;
82             this._super.apply(this, arguments);
83         },
84         /**
85          * Replace do_add_record to handle editability (and adding new record
86          * as an editable row at the top or bottom of the list)
87          */
88         do_add_record: function () {
89             if (this.editable()) {
90                 this.$element.find('table:first').show();
91                 this.$element.find('.oe_view_nocontent').remove();
92                 this.start_edition();
93             } else {
94                 this._super();
95             }
96         },
97         on_loaded: function (data, grouped) {
98             var self = this;
99             // tree/@editable takes priority on everything else if present.
100             var result = this._super(data, grouped);
101             if (this.editable()) {
102                 this.$element.addClass('oe_list_editable');
103                 // FIXME: any hook available to ensure this is only done once?
104                 this.$buttons
105                     .off('click', '.oe_list_save')
106                     .on('click', '.oe_list_save', this.proxy('save_edition'))
107                     .off('click', '.oe_list_discard')
108                     .on('click', '.oe_list_discard', function (e) {
109                         e.preventDefault();
110                         self.cancel_edition();
111                     });
112                 this.$element
113                     .off('click', 'tbody td:not(.oe_list_field_cell)')
114                     .on('click', 'tbody td:not(.oe_list_field_cell)', function () {
115                         if (!self.editor.is_editing()) {
116                             self.start_edition();
117                         }
118                     });
119                 this.editor.destroy();
120                 // Editor is not restartable due to formview not being
121                 // restartable
122                 this.editor = this.make_editor();
123                 var editor_ready = this.editor.prependTo(this.$element)
124                     .then(this.proxy('setup_events'));
125
126                 return $.when(result, editor_ready);
127             } else {
128                 this.$element.removeClass('oe_list_editable');
129             }
130
131             return result;
132         },
133         /**
134          * Builds a new editor object
135          *
136          * @return {instance.web.list.Editor}
137          */
138         make_editor: function () {
139             return new instance.web.list.Editor(this);
140         },
141         do_button_action: function (name, id, callback) {
142             var self = this, args = arguments;
143             this.ensure_saved().then(function (done) {
144                 if (!id && done.created) {
145                     id = done.record.get('id');
146                 }
147                 self.handle_button.call(self, name, id, callback);
148             });
149         },
150         /**
151          * Ensures the editable list is saved (saves any pending edition if
152          * needed, or tries to)
153          *
154          * Returns a deferred to the end of the saving.
155          *
156          * @returns {$.Deferred}
157          */
158         ensure_saved: function () {
159             if (!this.editor.is_editing()) {
160                 return $.when();
161             }
162             return this.save_edition();
163         },
164         /**
165          * Set up the edition of a record of the list view "inline"
166          *
167          * @param {instance.web.list.Record} [record] record to edit, leave empty to create a new record
168          * @param {Object} [options]
169          * @param {String} [options.focus_field] field to focus at start of edition
170          * @return {jQuery.Deferred}
171          */
172         start_edition: function (record, options) {
173             var self = this;
174             var item = false;
175             if (record) {
176                 item = record.attributes;
177             } else {
178                 var attrs = {id: false};
179                 _(this.columns).chain()
180                     .filter(function (x) { return x.tag === 'field'})
181                     .pluck('name')
182                     .each(function (field) { attrs[field] = false; });
183                 record = new instance.web.list.Record(attrs);
184                 this.records.add(record, {
185                     at: this.prepends_on_create() ? 0 : null});
186             }
187             var $recordRow = this.groups.get_row_for(record);
188             var cells = this.get_cells_for($recordRow);
189
190             return this.ensure_saved().pipe(function () {
191                 self.fields_for_resize.splice(0, self.fields_for_resize.length);
192                 return self.with_event('edit', {
193                     record: record.attributes,
194                     cancel: false
195                 }, function () {
196                     return self.editor.edit(item, function (field_name, field) {
197                         var cell = cells[field_name];
198                         if (!cell || field.get('effective_readonly')) {
199                             // Readonly fields can just remain the list's,
200                             // form's usually don't have backgrounds &al
201                             field.set({invisible: true});
202                             return;
203                         }
204
205                         // FIXME: need better way to get the field back from bubbling (delegated) DOM events somehow
206                         field.$element.attr('data-fieldname', field_name);
207                         self.fields_for_resize.push({field: field, cell: cell});
208                     }, options).pipe(function () {
209                         $recordRow.addClass('oe_edition');
210                         self.resize_fields();
211                         return record.attributes;
212                     });
213                 }).fail(function () {
214                     // if the start_edition event is cancelled and it was a
215                     // creation, remove the newly-created empty record
216                     if (!record.get('id')) {
217                         self.records.remove(record);
218                     }
219                 });
220             });
221         },
222         get_cells_for: function ($row) {
223             var cells = {};
224             $row.children('td').each(function (index, el) {
225                 cells[el.getAttribute('data-field')] = el
226             });
227             return cells;
228         },
229         /**
230          * If currently editing a row, resizes all registered form fields based
231          * on the corresponding row cell
232          */
233         resize_fields: function () {
234             if (!this.editor.is_editing()) { return; }
235             for(var i=0, len=this.fields_for_resize.length; i<len; ++i) {
236                 var item = this.fields_for_resize[i];
237                 this.resize_field(item.field, item.cell);
238             }
239         },
240         /**
241          * Resizes a field's root element based on the corresponding cell of
242          * a listview row
243          *
244          * @param {instance.web.form.AbstractField} field
245          * @param {jQuery} cell
246          */
247         resize_field: function (field, cell) {
248             var $cell = $(cell);
249             var position = $cell.position();
250
251             // jquery does not understand !important
252             field.$element.attr('style', 'width: '+$cell.outerWidth()+'px !important')
253             field.$element.css({
254                 top: position.top,
255                 left: position.left,
256                 minHeight: $cell.outerHeight()
257             });
258         },
259         /**
260          * @return {jQuery.Deferred}
261          */
262         save_edition: function () {
263             var self = this;
264             return this.with_event('save', {
265                 editor: this.editor,
266                 form: this.editor.form,
267                 cancel: false
268             }, function () {
269                 return this.editor.save().pipe(function (attrs) {
270                     var created = false;
271                     var record = self.records.get(attrs.id);
272                     if (!record) {
273                         // new record
274                         created = true;
275                         record = self.records.find(function (r) {
276                             return !r.get('id');
277                         }).set('id', attrs.id);
278                     }
279                     // onwrite callback could be altering & reloading the
280                     // record which has *just* been saved, so first perform all
281                     // onwrites then do a final reload of the record
282                     return self.handle_onwrite(record)
283                         .pipe(function () {
284                             return self.reload_record(record); })
285                         .pipe(function () {
286                             return { created: created, record: record }; });
287                 });
288             });
289         },
290         /**
291          * @param {Boolean} [force=false] discards the data even if the form has been edited
292          * @return {jQuery.Deferred}
293          */
294         cancel_edition: function (force) {
295             var self = this;
296             return this.with_event('cancel', {
297                 editor: this.editor,
298                 form: this.editor.form,
299                 cancel: false
300             }, function () {
301                 return this.editor.cancel(force).pipe(function (attrs) {
302                     if (attrs.id) {
303                         var record = self.records.get(attrs.id);
304                         if (!record) {
305                             // Record removed by third party during edition
306                             return
307                         }
308                         return self.reload_record(record);
309                     }
310                     var to_delete = self.records.find(function (r) {
311                         return !r.get('id');
312                     });
313                     if (to_delete) {
314                         self.records.remove(to_delete);
315                     }
316                 });
317             });
318         },
319         /**
320          * Executes an action on the view's editor bracketed by a cancellable
321          * event of the name provided.
322          *
323          * The event name provided will be post-fixed with ``:before`` and
324          * ``:after``, the ``event`` parameter will be passed alongside the
325          * ``:before`` variant and if the parameter's ``cancel`` key is set to
326          * ``true`` the action *will not be called* and the method will return
327          * a rejection
328          *
329          * @param {String} event_name name of the event
330          * @param {Object} event event object, provided to ``:before`` sub-event
331          * @param {Function} action callable, called with the view's editor as its context
332          * @param {Array} [args] supplementary arguments provided to the action
333          * @param {Array} [trigger_params] supplementary arguments provided to the ``:after`` sub-event, before anything fetched by the ``action`` function
334          * @return {jQuery.Deferred}
335          */
336         with_event: function (event_name, event, action) {
337             var self = this;
338             event = event || {};
339             this.trigger(event_name + ':before', event);
340             if (event.cancel) {
341                 return $.Deferred().reject({
342                     message: _.str.sprintf("Event %s:before cancelled",
343                                            event_name)});
344             }
345             return $.when(action.call(this)).then(function () {
346                 self.trigger.apply(self, [event_name + ':after']
347                         .concat(_.toArray(arguments)));
348             });
349         },
350         edition_view: function (editor) {
351             var view = $.extend(true, {}, this.fields_view);
352             view.arch.tag = 'form';
353             _.extend(view.arch.attrs, {
354                 'class': 'oe_form_container',
355                 version: '7.0'
356             });
357             _(view.arch.children).each(function (widget) {
358                 var modifiers = JSON.parse(widget.attrs.modifiers || '{}');
359                 widget.attrs.nolabel = true;
360                 if (modifiers['tree_invisible'] || widget.tag === 'button') {
361                     modifiers.invisible = true;
362                 }
363                 widget.attrs.modifiers = JSON.stringify(modifiers);
364             });
365             return view;
366         },
367         handle_onwrite: function (source_record) {
368             var self = this;
369             var on_write_callback = self.fields_view.arch.attrs.on_write;
370             if (!on_write_callback) { return $.when(); }
371             return this.dataset.call(on_write_callback, [source_record.get('id')])
372                 .pipe(function (ids) {
373                     return $.when.apply(
374                         null, _(ids).map(
375                             _.bind(self.handle_onwrite_record, self, source_record)));
376                 });
377         },
378         handle_onwrite_record: function (source_record, id) {
379             var record = this.records.get(id);
380             if (!record) {
381                 // insert after the source record
382                 var index = this.records.indexOf(source_record) + 1;
383                 record = new instance.web.list.Record({id: id});
384                 this.records.add(record, {at: index});
385                 this.dataset.ids.splice(index, 0, id);
386             }
387             return this.reload_record(record);
388         },
389         prepends_on_create: function () {
390             return this.editable() === 'top';
391         },
392         setup_events: function () {
393             var self = this;
394             this.editor.$element.on('keyup keydown', function (e) {
395                 if (!self.editor.is_editing()) { return; }
396                 var key = _($.ui.keyCode).chain()
397                     .map(function (v, k) { return {name: k, code: v}; })
398                     .find(function (o) { return o.code === e.which; })
399                     .value();
400                 if (!key) { return; }
401                 var method = e.type + '_' + key.name;
402                 if (!(method in self)) { return; }
403                 self[method](e);
404             });
405         },
406         /**
407          * Saves the current record, and goes to the next one (creation or
408          * edition)
409          *
410          * @private
411          * @param {String} [next_record='succ'] method to call on the records collection to get the next record to edit
412          * @param {Object} [options]
413          * @param {String} [options.focus_field]
414          * @return {*}
415          */
416         _next: function (next_record, options) {
417             next_record = next_record || 'succ';
418             var self = this;
419             return this.save_edition().pipe(function (saveInfo) {
420                 if (saveInfo.created) {
421                     return self.start_edition();
422                 }
423                 var record = self.records[next_record](
424                         saveInfo.record, {wraparound: true});
425                 return self.start_edition(record, options);
426             });
427         },
428         keyup_ENTER: function () {
429             return this._next();
430         },
431         keyup_ESCAPE: function () {
432             return this.cancel_edition();
433         },
434         /**
435          * Gets the selection range (start, end) for the provided element,
436          * returns ``null`` if it can't get a range.
437          *
438          * @private
439          */
440         _text_selection_range: function (el) {
441             var selectionStart;
442             try {
443                 selectionStart = el.selectionStart;
444             } catch (e) {
445                 // radio or checkbox throw on selectionStart access
446                 return null;
447             }
448             if (selectionStart !== undefined) {
449                 return {
450                     start: selectionStart,
451                     end: el.selectionEnd
452                 };
453             } else if (document.body.createTextRange) {
454                 throw new Error("Implement text range handling for MSIE");
455                 var sel = document.body.createTextRange();
456                 if (sel.parentElement() === el) {
457
458                 }
459             }
460             // Element without selection ranges (select, div/@contenteditable)
461             return null;
462         },
463         _text_cursor: function (el) {
464             var selection = this._text_selection_range(el);
465             if (!selection) {
466                 return null;
467             }
468             if (selection.start !== selection.end) {
469                 return {position: null, collapsed: false};
470             }
471             return {position: selection.start, collapsed: true};
472         },
473         /**
474          * Checks if the cursor is at the start of the provided el
475          *
476          * @param {HTMLInputElement | HTMLTextAreaElement}
477          * @returns {Boolean}
478          * @private
479          */
480         _at_start: function (cursor, el) {
481             return cursor.collapsed && (cursor.position === 0);
482         },
483         /**
484          * Checks if the cursor is at the end of the provided el
485          *
486          * @param {HTMLInputElement | HTMLTextAreaElement}
487          * @returns {Boolean}
488          * @private
489          */
490         _at_end: function (cursor, el) {
491             return cursor.collapsed && (cursor.position === el.value.length);
492         },
493         /**
494          * @param DOMEvent event
495          * @param {String} record_direction direction to move into to get the next record (pred | succ)
496          * @param {Function} is_valid_move whether the edition should be moved to the next record
497          * @private
498          */
499         _key_move_record: function (event, record_direction, is_valid_move) {
500             if (!this.editor.is_editing('edit')) { return $.when(); }
501             var cursor = this._text_cursor(event.target);
502             // if text-based input (has a cursor)
503             //    and selecting (not collapsed) or not at a field boundary
504             //        don't move to the next record
505             if (cursor && !is_valid_move(event.target, cursor)) { return $.when(); }
506
507             event.preventDefault();
508             var source_field = $(event.target).closest('[data-fieldname]')
509                     .attr('data-fieldname');
510             return this._next(record_direction, {focus_field: source_field});
511
512         },
513         keydown_UP: function (e) {
514             var self = this;
515             return this._key_move_record(e, 'pred', function (el, cursor) {
516                 return self._at_start(cursor, el);
517             });
518         },
519         keydown_DOWN: function (e) {
520             var self = this;
521             return this._key_move_record(e, 'succ', function (el, cursor) {
522                 return self._at_end(cursor, el);
523             });
524         },
525
526         keydown_LEFT: function (e) {
527             // If the cursor is at the beginning of the field
528             var source_field = $(e.target).closest('[data-fieldname]')
529                     .attr('data-fieldname');
530             var cursor = this._text_cursor(e.target);
531             if (cursor && !this._at_start(cursor, e.target)) { return $.when(); }
532
533             var fields_order = this.editor.form.fields_order;
534             var field_index = _(fields_order).indexOf(source_field);
535
536             // Look for the closest visible form field to the left
537             var fields = this.editor.form.fields;
538             var field;
539             do {
540                 if (--field_index < 0) { return $.when(); }
541
542                 field = fields[fields_order[field_index]];
543             } while (!field.$element.is(':visible'));
544
545             // and focus it
546             field.focus();
547             return $.when();
548         },
549         keydown_RIGHT: function (e) {
550             // same as above, but with cursor at the end of the field and
551             // looking for new fields at the right
552             var source_field = $(e.target).closest('[data-fieldname]')
553                     .attr('data-fieldname');
554             var cursor = this._text_cursor(e.target);
555             if (cursor && !this._at_end(cursor, e.target)) { return $.when(); }
556
557             var fields_order = this.editor.form.fields_order;
558             var field_index = _(fields_order).indexOf(source_field);
559
560             var fields = this.editor.form.fields;
561             var field;
562             do {
563                 if (++field_index >= fields_order.length) { return $.when(); }
564
565                 field = fields[fields_order[field_index]];
566             } while (!field.$element.is(':visible'));
567
568             field.focus();
569             return $.when();
570         },
571         keydown_TAB: function (e) {
572             var form = this.editor.form;
573             var last_field = _(form.fields_order).chain()
574                 .map(function (name) { return form.fields[name]; })
575                 .filter(function (field) { return field.$element.is(':visible'); })
576                 .last()
577                 .value();
578             // tabbed from last field in form
579             if (last_field && last_field.$element.has(e.target).length) {
580                 e.preventDefault();
581                 return this._next();
582             }
583             return $.when();
584         }
585     });
586
587     instance.web.list.Editor = instance.web.Widget.extend({
588         /**
589          * @constructs instance.web.list.Editor
590          * @extends instance.web.Widget
591          *
592          * Adapter between listview and formview for editable-listview purposes
593          *
594          * @param {instance.web.Widget} parent
595          * @param {Object} options
596          * @param {instance.web.FormView} [options.formView=instance.web.FormView]
597          * @param {Object} [options.delegate]
598          */
599         init: function (parent, options) {
600             this._super(parent);
601             this.options = options || {};
602             _.defaults(this.options, {
603                 formView: instance.web.FormView,
604                 delegate: this.getParent()
605             });
606             this.delegate = this.options.delegate;
607
608             this.record = null;
609
610             this.form = new (this.options.formView)(
611                 this, this.delegate.dataset, false, {
612                     initial_mode: 'edit',
613                     disable_autofocus: true,
614                     $buttons: $(),
615                     $pager: $()
616             });
617         },
618         start: function () {
619             var self = this;
620             var _super = this._super();
621             this.form.embedded_view = this._validate_view(
622                     this.delegate.edition_view(this));
623             var form_ready = this.form.appendTo(this.$element).then(
624                 self.form.proxy('do_hide'));
625             return $.when(_super, form_ready);
626         },
627         _validate_view: function (edition_view) {
628             if (!edition_view) {
629                 throw new Error("editor delegate's #edition_view must return "
630                               + "a view descriptor");
631             }
632             var arch = edition_view.arch;
633             if (!(arch && arch.children instanceof Array)) {
634                 throw new Error("Editor delegate's #edition_view must have a" +
635                                 " non-empty arch")
636             }
637             if (!(arch.tag === "form")) {
638                 throw new Error("Editor delegate's #edition_view must have a" +
639                                 " 'form' root node");
640             }
641             if (!(arch.attrs && arch.attrs.version === "7.0")) {
642                 throw new Error("Editor delegate's #edition_view must be a" +
643                                 " version 7 view");
644             }
645             if (!/\boe_form_container\b/.test(arch.attrs['class'])) {
646                 throw new Error("Editor delegate's #edition_view must have the" +
647                                 " class 'oe_form_container' on its root" +
648                                 " element");
649             }
650
651             return edition_view;
652         },
653
654         /**
655          *
656          * @param {String} [state] either ``new`` or ``edit``
657          * @return {Boolean}
658          */
659         is_editing: function (state) {
660             if (!this.record) {
661                 return false;
662             }
663             switch(state) {
664             case null: case undefined:
665                 return true;
666             case 'new': return !this.record.id;
667             case 'edit': return !!this.record.id;
668             }
669             throw new Error("is_editing's state filter must be either `new` or" +
670                             " `edit` if provided");
671         },
672         _focus_setup: function (focus_field) {
673             var form = this.form;
674
675             var field;
676             // If a field to focus was specified
677             if (focus_field
678                     // Is actually in the form
679                     && (field = form.fields[focus_field])
680                     // And is visible
681                     && field.$element.is(':visible')) {
682                 // focus it
683                 field.focus();
684                 return;
685             }
686
687             _(form.fields_order).detect(function (name) {
688                 // look for first visible field in fields_order, focus it
689                 var field = form.fields[name];
690                 if (!field.$element.is(':visible')) {
691                     return false;
692                 }
693                 // Stop as soon as a field got focused
694                 return field.focus() !== false;
695             });
696         },
697         edit: function (record, configureField, options) {
698             // TODO: specify sequence of edit calls
699             var self = this;
700             var form = self.form;
701             var loaded = record
702                 ? form.on_record_loaded(_.extend({}, record))
703                 : form.load_defaults();
704
705             return loaded.pipe(function () {
706                 return form.do_show({reload: false});
707             }).pipe(function () {
708                 self.record = form.datarecord;
709                 _(form.fields).each(function (field, name) {
710                     configureField(name, field);
711                 });
712                 self._focus_setup(options && options.focus_field);
713                 return form;
714             });
715         },
716         save: function () {
717             var self = this;
718             return this.form
719                 .do_save(null, this.delegate.prepends_on_create())
720                 .pipe(function (result) {
721                     var created = result.created && !self.record.id;
722                     if (created) {
723                         self.record.id = result.result;
724                     }
725                     return self.cancel();
726                 });
727         },
728         cancel: function (force) {
729             if (!(force || this.form.can_be_discarded())) {
730                 return $.Deferred().reject({
731                     message: "The form's data can not be discarded"}).promise();
732             }
733             var record = this.record;
734             this.record = null;
735             this.form.do_hide();
736             return $.when(record);
737         }
738     });
739
740     instance.web.ListView.Groups.include(/** @lends instance.web.ListView.Groups# */{
741         passtrough_events: instance.web.ListView.Groups.prototype.passtrough_events + " edit saved",
742         get_row_for: function (record) {
743             return _(this.children).chain()
744                 .invoke('get_row_for', record)
745                 .compact()
746                 .first()
747                 .value();
748         }
749     });
750
751     instance.web.ListView.List.include(/** @lends instance.web.ListView.List# */{
752         row_clicked: function (event) {
753             if (!this.view.editable()) {
754                 return this._super.apply(this, arguments);
755             }
756             var record_id = $(event.currentTarget).data('id');
757             this.view.start_edition(
758                 record_id ? this.records.get(record_id) : null, {
759                 focus_field: $(event.target).data('field')
760             });
761         },
762         /**
763          * If a row mapping to the record (@data-id matching the record's id or
764          * no @data-id if the record has no id), returns it. Otherwise returns
765          * ``null``.
766          *
767          * @param {Record} record the record to get a row for
768          * @return {jQuery|null}
769          */
770         get_row_for: function (record) {
771             var id;
772             var $row = this.$current.children('[data-id=' + record.get('id') + ']');
773             if ($row.length) {
774                 return $row;
775             }
776             return null;
777         }
778     });
779 };