2 * handles editability case for lists, because it depends on form and forms already depends on lists it had to be split out
7 var instance = openerp;
8 openerp.web.list_editable = {};
9 var _t = instance.web._t;
11 // editability status of list rows
12 instance.web.ListView.prototype.defaults.editable = null;
14 // TODO: not sure second @lends on existing item is correct, to check
15 instance.web.ListView.include(/** @lends instance.web.ListView# */{
18 this._super.apply(this, arguments);
20 this.saving_mutex = new $.Mutex();
22 this._force_editability = null;
23 this._context_editable = false;
24 this.editor = this.make_editor();
25 // Stores records of {field, cell}, allows for re-rendering fields
26 // depending on cell state during and after resize events
27 this.fields_for_resize = [];
28 instance.web.bus.on('resize', this, this.resize_fields);
31 'edit': function (e, id, dataset) {
32 self.do_edit(dataset.index, id, dataset);
34 'saved': function () {
35 if (self.groups.get_selection().length) {
38 self.configure_pager(self.dataset);
39 self.compute_aggregates();
43 this.records.bind('remove', function () {
44 if (self.editor.is_editing()) {
45 self.cancel_edition();
49 this.on('edit:before', this, function (event) {
50 if (!self.editable() || self.editor.is_editing()) {
54 this.on('edit:after', this, function () {
55 self.$el.add(self.$buttons).addClass('oe_editing');
56 self.$('.ui-sortable').sortable('disable');
58 this.on('save:after cancel:after', this, function () {
59 self.$('.ui-sortable').sortable('enable');
60 self.$el.add(self.$buttons).removeClass('oe_editing');
63 destroy: function () {
64 instance.web.bus.off('resize', this, this.resize_fields);
67 do_hide: function () {
68 if (this.editor.is_editing()) {
69 this.cancel_edition(true);
73 sort_by_column: function (e) {
75 if (!this.editor.is_editing()) {
76 this._super.apply(this, arguments);
80 * Handles the activation of a record in editable mode (making a record
81 * editable), called *after* the record has become editable.
83 * The default behavior is to setup the listview's dataset to match
84 * whatever dataset was provided by the editing List
86 * @param {Number} index index of the record in the dataset
87 * @param {Object} id identifier of the record being edited
88 * @param {instance.web.DataSet} dataset dataset in which the record is available
90 do_edit: function (index, id, dataset) {
91 _.extend(this.dataset, dataset);
93 do_delete: function (ids) {
94 var nonfalse = _.compact(ids);
95 var _super = this._super.bind(this);
96 var next = this.editor.is_editing()
97 ? this.cancel_edition(true)
99 return next.then(function () {
100 return _super(nonfalse);
103 editable: function () {
105 && !this.options.disable_editable_mode
106 && (this.fields_view.arch.attrs.editable
107 || this._context_editable
108 || this.options.editable);
111 * Replace do_search to handle editability process
113 do_search: function(domain, context, group_by) {
114 var self=this, _super = self._super, args=arguments;
115 var ready = this.editor.is_editing()
116 ? this.cancel_edition(true)
119 return ready.then(function () {
120 self._context_editable = !!context.set_editable;
121 return _super.apply(self, args);
125 * Replace do_add_record to handle editability (and adding new record
126 * as an editable row at the top or bottom of the list)
128 do_add_record: function () {
130 if (this.editable()) {
131 this.$el.find('table:first').show();
132 this.$el.find('.oe_view_nocontent').remove();
133 this.start_edition();
138 load_list: function (data, grouped) {
140 // tree/@editable takes priority on everything else if present.
141 var result = this._super(data, grouped);
143 // In case current editor was started previously, also has to run
144 // when toggling from editable to non-editable in case form widgets
145 // have setup global behaviors expecting themselves to exist
147 this.editor.destroy();
148 // Editor is not restartable due to formview not being restartable
149 this.editor = this.make_editor();
151 if (this.editable()) {
152 this.$el.addClass('oe_list_editable');
153 // FIXME: any hook available to ensure this is only done once?
155 .off('click', '.oe_list_save')
156 .on('click', '.oe_list_save', this.proxy('save_edition'))
157 .off('click', '.oe_list_discard')
158 .on('click', '.oe_list_discard', function (e) {
160 self.cancel_edition();
162 var editor_ready = this.editor.prependTo(this.$el)
163 .done(this.proxy('setup_events'));
165 return $.when(result, editor_ready);
167 this.$el.removeClass('oe_list_editable');
173 * Builds a new editor object
175 * @return {instance.web.list.Editor}
177 make_editor: function () {
178 return new instance.web.list.Editor(this);
180 do_button_action: function (name, id, callback) {
181 var self = this, args = arguments;
182 this.ensure_saved().done(function (done) {
183 if (!id && done.created) {
184 id = done.record.get('id');
186 self.handle_button(name, id, callback);
190 * Ensures the editable list is saved (saves any pending edition if
191 * needed, or tries to)
193 * Returns a deferred to the end of the saving.
195 * @returns {$.Deferred}
197 ensure_saved: function () {
198 return this.save_edition();
201 * Builds a record with the provided id (``false`` for a creation),
202 * setting all columns with ``false`` value so code which relies on
203 * having an actual value behaves correctly
206 * @return {instance.web.list.Record}
208 make_empty_record: function (id) {
209 var attrs = {id: id};
210 _(this.columns).chain()
211 .filter(function (x) { return x.tag === 'field';})
213 .each(function (field) { attrs[field] = false; });
214 return new instance.web.list.Record(attrs);
217 * Set up the edition of a record of the list view "inline"
219 * @param {instance.web.list.Record} [record] record to edit, leave empty to create a new record
220 * @param {Object} [options]
221 * @param {String} [options.focus_field] field to focus at start of edition
222 * @return {jQuery.Deferred}
224 start_edition: function (record, options) {
228 item = record.attributes;
230 record = this.make_empty_record(false);
231 this.records.add(record, {
232 at: this.prepends_on_create() ? 0 : null});
235 return this.ensure_saved().then(function () {
236 var $recordRow = self.groups.get_row_for(record);
237 var cells = self.get_cells_for($recordRow);
239 self.fields_for_resize.splice(0, self.fields_for_resize.length);
240 return self.with_event('edit', {
241 record: record.attributes,
244 return self.editor.edit(item, function (field_name, field) {
245 var cell = cells[field_name];
250 // FIXME: need better way to get the field back from bubbling (delegated) DOM events somehow
251 field.$el.attr('data-fieldname', field_name);
252 fields[field_name] = field;
253 self.fields_for_resize.push({field: field, cell: cell});
254 }, options).then(function () {
255 $recordRow.addClass('oe_edition');
256 self.resize_fields();
257 var focus_field = options && options.focus_field ? options.focus_field : undefined;
259 focus_field = _.find(self.editor.form.fields_order, function(field){ return fields[field] && fields[field].$el.is(':visible:has(input)'); });
261 if (focus_field) fields[focus_field].$el.find('input').select();
262 return record.attributes;
264 }).fail(function () {
265 // if the start_edition event is cancelled and it was a
266 // creation, remove the newly-created empty record
267 if (!record.get('id')) {
268 self.records.remove(record);
273 get_cells_for: function ($row) {
275 $row.children('td').each(function (index, el) {
276 cells[el.getAttribute('data-field')] = el;
281 * If currently editing a row, resizes all registered form fields based
282 * on the corresponding row cell
284 resize_fields: function () {
285 if (!this.editor.is_editing()) { return; }
286 for(var i=0, len=this.fields_for_resize.length; i<len; ++i) {
287 var item = this.fields_for_resize[i];
288 if (!item.field.get('effective_invisible')) {
289 this.resize_field(item.field, item.cell);
294 * Resizes a field's root element based on the corresponding cell of
297 * @param {instance.web.form.AbstractField} field
298 * @param {jQuery} cell
300 resize_field: function (field, cell) {
303 field.set_dimensions($cell.outerHeight(), $cell.outerWidth());
311 * @return {jQuery.Deferred}
313 save_edition: function () {
315 return self.saving_mutex.exec(function() {
316 if (!self.editor.is_editing()) {
319 return self.with_event('save', {
321 form: self.editor.form,
324 return self.editor.save().then(function (attrs) {
326 var record = self.records.get(attrs.id);
330 record = self.records.find(function (r) {
332 }).set('id', attrs.id);
334 // onwrite callback could be altering & reloading the
335 // record which has *just* been saved, so first perform all
336 // onwrites then do a final reload of the record
337 return self.handle_onwrite(record)
339 return self.reload_record(record); })
341 return { created: created, record: record }; });
347 * @param {Boolean} [force=false] discards the data even if the form has been edited
348 * @return {jQuery.Deferred}
350 cancel_edition: function (force) {
352 return this.with_event('cancel', {
354 form: this.editor.form,
357 return this.editor.cancel(force).then(function (attrs) {
359 var record = self.records.get(attrs.id);
361 // Record removed by third party during edition
364 return self.reload_record(record);
366 var to_delete = self.records.find(function (r) {
370 self.records.remove(to_delete);
376 * Executes an action on the view's editor bracketed by a cancellable
377 * event of the name provided.
379 * The event name provided will be post-fixed with ``:before`` and
380 * ``:after``, the ``event`` parameter will be passed alongside the
381 * ``:before`` variant and if the parameter's ``cancel`` key is set to
382 * ``true`` the action *will not be called* and the method will return
385 * @param {String} event_name name of the event
386 * @param {Object} event event object, provided to ``:before`` sub-event
387 * @param {Function} action callable, called with the view's editor as its context
388 * @param {Array} [args] supplementary arguments provided to the action
389 * @param {Array} [trigger_params] supplementary arguments provided to the ``:after`` sub-event, before anything fetched by the ``action`` function
390 * @return {jQuery.Deferred}
392 with_event: function (event_name, event, action) {
395 this.trigger(event_name + ':before', event);
397 return $.Deferred().reject({
398 message: _.str.sprintf("Event %s:before cancelled",
401 return $.when(action.call(this)).done(function () {
402 self.trigger.apply(self, [event_name + ':after']
403 .concat(_.toArray(arguments)));
406 edition_view: function (editor) {
407 var view = $.extend(true, {}, this.fields_view);
408 view.arch.tag = 'form';
409 _.extend(view.arch.attrs, {
410 'class': 'oe_form_container',
413 _(view.arch.children).chain()
414 .zip(_(this.columns).filter(function (c) {
415 return !(c instanceof instance.web.list.MetaColumn);}))
416 .each(function (ar) {
417 var widget = ar[0], column = ar[1];
418 var modifiers = _.extend({}, column.modifiers);
419 widget.attrs.nolabel = true;
420 if (modifiers['tree_invisible'] || widget.tag === 'button') {
421 modifiers.invisible = true;
423 widget.attrs.modifiers = JSON.stringify(modifiers);
427 handle_onwrite: function (source_record) {
429 var on_write_callback = self.fields_view.arch.attrs.on_write;
430 if (!on_write_callback) { return $.when(); }
431 return this.dataset.call(on_write_callback, [source_record.get('id')])
432 .then(function (ids) {
435 _.bind(self.handle_onwrite_record, self, source_record)));
438 handle_onwrite_record: function (source_record, id) {
439 var record = this.records.get(id);
441 // insert after the source record
442 var index = this.records.indexOf(source_record) + 1;
443 record = this.make_empty_record(id);
444 this.records.add(record, {at: index});
446 return this.reload_record(record);
448 prepends_on_create: function () {
449 return this.editable() === 'top';
451 setup_events: function () {
453 _.each(this.editor.form.fields, function(field, field_name) {
454 var set_invisible = function() {
455 field.set({'force_invisible': field.get('effective_readonly')});
457 field.on("change:effective_readonly", self, set_invisible);
459 field.on('change:effective_invisible', self, function () {
460 if (field.get('effective_invisible')) { return; }
461 var item = _(self.fields_for_resize).find(function (item) {
462 return item.field === field;
465 setTimeout(function() {
466 self.resize_field(item.field, item.cell);
473 this.editor.$el.on('keyup keypress keydown', function (e) {
474 if (!self.editor.is_editing()) { return true; }
475 var key = _($.ui.keyCode).chain()
476 .map(function (v, k) { return {name: k, code: v}; })
477 .find(function (o) { return o.code === e.which; })
479 if (!key) { return true; }
480 var method = e.type + '_' + key.name;
481 if (!(method in self)) { return true; }
482 return self[method](e);
486 * Saves the current record, and goes to the next one (creation or
490 * @param {String} [next_record='succ'] method to call on the records collection to get the next record to edit
491 * @param {Object} [options]
492 * @param {String} [options.focus_field]
495 _next: function (next_record, options) {
496 next_record = next_record || 'succ';
498 return this.save_edition().then(function (saveInfo) {
499 if (!saveInfo) { return null; }
500 if (saveInfo.created) {
501 return self.start_edition();
503 var record = self.records[next_record](
504 saveInfo.record, {wraparound: true});
505 return self.start_edition(record, options);
508 keypress_ENTER: function () {
511 keydown_ESCAPE: function (e) {
514 keyup_ESCAPE: function (e) {
515 return this.cancel_edition();
518 * Gets the selection range (start, end) for the provided element,
519 * returns ``null`` if it can't get a range.
523 _text_selection_range: function (el) {
526 selectionStart = el.selectionStart;
528 // radio or checkbox throw on selectionStart access
531 if (selectionStart !== undefined) {
533 start: selectionStart,
536 } else if (document.body.createTextRange) {
537 throw new Error("Implement text range handling for MSIE");
539 // Element without selection ranges (select, div/@contenteditable)
542 _text_cursor: function (el) {
543 var selection = this._text_selection_range(el);
547 if (selection.start !== selection.end) {
548 return {position: null, collapsed: false};
550 return {position: selection.start, collapsed: true};
553 * Checks if the cursor is at the start of the provided el
555 * @param {HTMLInputElement | HTMLTextAreaElement}
559 _at_start: function (cursor, el) {
560 return cursor.collapsed && (cursor.position === 0);
563 * Checks if the cursor is at the end of the provided el
565 * @param {HTMLInputElement | HTMLTextAreaElement}
569 _at_end: function (cursor, el) {
570 return cursor.collapsed && (cursor.position === el.value.length);
573 * @param DOMEvent event
574 * @param {String} record_direction direction to move into to get the next record (pred | succ)
575 * @param {Function} is_valid_move whether the edition should be moved to the next record
578 _key_move_record: function (event, record_direction, is_valid_move) {
579 if (!this.editor.is_editing('edit')) { return $.when(); }
580 var cursor = this._text_cursor(event.target);
581 // if text-based input (has a cursor)
582 // and selecting (not collapsed) or not at a field boundary
583 // don't move to the next record
584 if (cursor && !is_valid_move(event.target, cursor)) { return $.when(); }
586 event.preventDefault();
587 var source_field = $(event.target).closest('[data-fieldname]')
588 .attr('data-fieldname');
589 return this._next(record_direction, {focus_field: source_field});
592 keydown_UP: function (e) {
594 return this._key_move_record(e, 'pred', function (el, cursor) {
595 return self._at_start(cursor, el);
598 keydown_DOWN: function (e) {
600 return this._key_move_record(e, 'succ', function (el, cursor) {
601 return self._at_end(cursor, el);
605 keydown_LEFT: function (e) {
606 // If the cursor is at the beginning of the field
607 var source_field = $(e.target).closest('[data-fieldname]')
608 .attr('data-fieldname');
609 var cursor = this._text_cursor(e.target);
610 if (cursor && !this._at_start(cursor, e.target)) { return $.when(); }
612 var fields_order = this.editor.form.fields_order;
613 var field_index = _(fields_order).indexOf(source_field);
615 // Look for the closest visible form field to the left
616 var fields = this.editor.form.fields;
619 if (--field_index < 0) { return $.when(); }
621 field = fields[fields_order[field_index]];
622 } while (!field.$el.is(':visible'));
628 keydown_RIGHT: function (e) {
629 // same as above, but with cursor at the end of the field and
630 // looking for new fields at the right
631 var source_field = $(e.target).closest('[data-fieldname]')
632 .attr('data-fieldname');
633 var cursor = this._text_cursor(e.target);
634 if (cursor && !this._at_end(cursor, e.target)) { return $.when(); }
636 var fields_order = this.editor.form.fields_order;
637 var field_index = _(fields_order).indexOf(source_field);
639 var fields = this.editor.form.fields;
642 if (++field_index >= fields_order.length) { return $.when(); }
644 field = fields[fields_order[field_index]];
645 } while (!field.$el.is(':visible'));
650 keydown_TAB: function (e) {
651 var form = this.editor.form;
652 var last_field = _(form.fields_order).chain()
653 .map(function (name) { return form.fields[name]; })
654 .filter(function (field) { return field.$el.is(':visible'); })
657 // tabbed from last field in form
658 if (last_field && last_field.$el.has(e.target).length) {
666 instance.web.list.Editor = instance.web.Widget.extend({
668 * @constructs instance.web.list.Editor
669 * @extends instance.web.Widget
671 * Adapter between listview and formview for editable-listview purposes
673 * @param {instance.web.Widget} parent
674 * @param {Object} options
675 * @param {instance.web.FormView} [options.formView=instance.web.FormView]
676 * @param {Object} [options.delegate]
678 init: function (parent, options) {
680 this.options = options || {};
681 _.defaults(this.options, {
682 formView: instance.web.FormView,
683 delegate: this.getParent()
685 this.delegate = this.options.delegate;
689 this.form = new (this.options.formView)(
690 this, this.delegate.dataset, false, {
691 initial_mode: 'edit',
692 disable_autofocus: true,
699 var _super = this._super();
700 this.form.embedded_view = this._validate_view(
701 this.delegate.edition_view(this));
702 var form_ready = this.form.appendTo(this.$el).done(
703 self.form.proxy('do_hide'));
704 return $.when(_super, form_ready);
706 _validate_view: function (edition_view) {
708 throw new Error("editor delegate's #edition_view must return "
709 + "a view descriptor");
711 var arch = edition_view.arch;
712 if (!(arch && arch.children instanceof Array)) {
713 throw new Error("Editor delegate's #edition_view must have a" +
716 if (arch.tag !== "form") {
717 throw new Error("Editor delegate's #edition_view must have a" +
718 " 'form' root node");
720 if (!(arch.attrs && arch.attrs.version === "7.0")) {
721 throw new Error("Editor delegate's #edition_view must be a" +
724 if (!/\boe_form_container\b/.test(arch.attrs['class'])) {
725 throw new Error("Editor delegate's #edition_view must have the" +
726 " class 'oe_form_container' on its root" +
735 * @param {String} [state] either ``new`` or ``edit``
738 is_editing: function (state) {
743 case null: case undefined:
745 case 'new': return !this.record.id;
746 case 'edit': return !!this.record.id;
748 throw new Error("is_editing's state filter must be either `new` or" +
749 " `edit` if provided");
751 edit: function (record, configureField, options) {
752 // TODO: specify sequence of edit calls
754 var form = self.form;
756 ? form.trigger('load_record', _.extend({}, record))
757 : form.load_defaults();
758 return $.when(loaded).then(function () {
759 return form.do_show({reload: false});
760 }).then(function () {
761 self.record = form.datarecord;
762 _(form.fields).each(function (field, name) {
763 configureField(name, field);
771 .save(this.delegate.prepends_on_create())
772 .then(function (result) {
773 var created = result.created && !self.record.id;
775 self.record.id = result.result;
777 return self.cancel();
780 cancel: function (force) {
781 if (!(force || this.form.can_be_discarded())) {
782 return $.Deferred().reject({
783 message: _t("The form's data can not be discarded")}).promise();
785 var record = this.record;
788 return $.when(record);
792 instance.web.ListView.Groups.include(/** @lends instance.web.ListView.Groups# */{
793 passthrough_events: instance.web.ListView.Groups.prototype.passthrough_events + " edit saved",
794 get_row_for: function (record) {
795 return _(this.children).chain()
796 .invoke('get_row_for', record)
803 instance.web.ListView.List.include(/** @lends instance.web.ListView.List# */{
804 row_clicked: function (event) {
805 if (!this.view.editable() || ! this.view.is_action_enabled('edit')) {
806 return this._super.apply(this, arguments);
808 var record_id = $(event.currentTarget).data('id');
809 return this.view.start_edition(
810 record_id ? this.records.get(record_id) : null, {
811 focus_field: $(event.target).data('field')
815 * If a row mapping to the record (@data-id matching the record's id or
816 * no @data-id if the record has no id), returns it. Otherwise returns
819 * @param {Record} record the record to get a row for
820 * @return {jQuery|null}
822 get_row_for: function (record) {
824 var $row = this.$current.children('[data-id=' + record.get('id') + ']');