2 * handles editability case for lists, because it depends on form and forms already depends on lists it had to be split out
5 openerp.web.list_editable = function (instance) {
6 // editability status of list rows
7 instance.web.ListView.prototype.defaults.editable = null;
9 // TODO: not sure second @lends on existing item is correct, to check
10 instance.web.ListView.include(/** @lends instance.web.ListView# */{
13 this._super.apply(this, arguments);
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);
24 'edit': function (e, id, dataset) {
25 self.do_edit(dataset.index, id, dataset);
27 'saved': function () {
28 if (self.groups.get_selection().length) {
31 self.configure_pager(self.dataset);
32 self.compute_aggregates();
36 this.records.bind('remove', function () {
37 if (self.editor.is_editing()) {
38 self.cancel_edition();
42 this.on('edit:before', this, function (event) {
43 if (!self.editable() || self.editor.is_editing()) {
47 this.on('edit:after', this, function () {
48 self.$element.add(self.$buttons).addClass('oe_editing');
50 this.on('save:after cancel:after', this, function () {
51 self.$element.add(self.$buttons).removeClass('oe_editing');
54 destroy: function () {
55 instance.web.bus.off('resize', this, this.resize_fields);
59 * Handles the activation of a record in editable mode (making a record
60 * editable), called *after* the record has become editable.
62 * The default behavior is to setup the listview's dataset to match
63 * whatever dataset was provided by the editing List
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
69 do_edit: function (index, id, dataset) {
70 _.extend(this.dataset, dataset);
72 editable: function () {
73 return this.fields_view.arch.attrs.editable
74 || this._context_editable
75 || this.options.editable;
78 * Replace do_search to handle editability process
80 do_search: function(domain, context, group_by) {
81 this._context_editable = !!context.set_editable;
82 this._super.apply(this, arguments);
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)
88 do_add_record: function () {
89 if (this.editable()) {
90 this.$element.find('table:first').show();
91 this.$element.find('.oe_view_nocontent').remove();
97 on_loaded: function (data, grouped) {
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?
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) {
110 self.cancel_edition();
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();
119 this.editor.destroy();
120 // Editor is not restartable due to formview not being
122 this.editor = this.make_editor();
123 var editor_ready = this.editor.prependTo(this.$element)
124 .then(this.proxy('setup_events'));
126 return $.when(result, editor_ready);
128 this.$element.removeClass('oe_list_editable');
134 * Builds a new editor object
136 * @return {instance.web.list.Editor}
138 make_editor: function () {
139 return new instance.web.list.Editor(this);
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');
147 self.handle_button.call(self, name, id, callback);
151 * Ensures the editable list is saved (saves any pending edition if
152 * needed, or tries to)
154 * Returns a deferred to the end of the saving.
156 * @returns {$.Deferred}
158 ensure_saved: function () {
159 if (!this.editor.is_editing()) {
162 return this.save_edition();
165 * Set up the edition of a record of the list view "inline"
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}
172 start_edition: function (record, options) {
176 item = record.attributes;
178 var attrs = {id: false};
179 _(this.columns).chain()
180 .filter(function (x) { return x.tag === 'field'})
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});
187 var $recordRow = this.groups.get_row_for(record);
188 var cells = this.get_cells_for($recordRow);
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,
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});
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;
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);
222 get_cells_for: function ($row) {
224 $row.children('td').each(function (index, el) {
225 cells[el.getAttribute('data-field')] = el
230 * If currently editing a row, resizes all registered form fields based
231 * on the corresponding row cell
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);
241 * Resizes a field's root element based on the corresponding cell of
244 * @param {instance.web.form.AbstractField} field
245 * @param {jQuery} cell
247 resize_field: function (field, cell) {
249 var position = $cell.position();
251 // jquery does not understand !important
252 field.$element.attr('style', 'width: '+$cell.outerWidth()+'px !important')
256 minHeight: $cell.outerHeight()
260 * @return {jQuery.Deferred}
262 save_edition: function () {
264 return this.with_event('save', {
266 form: this.editor.form,
269 return this.editor.save().pipe(function (attrs) {
271 var record = self.records.get(attrs.id);
275 record = self.records.find(function (r) {
277 }).set('id', attrs.id);
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)
284 return self.reload_record(record); })
286 return { created: created, record: record }; });
291 * @param {Boolean} [force=false] discards the data even if the form has been edited
292 * @return {jQuery.Deferred}
294 cancel_edition: function (force) {
296 return this.with_event('cancel', {
298 form: this.editor.form,
301 return this.editor.cancel(force).pipe(function (attrs) {
303 var record = self.records.get(attrs.id);
305 // Record removed by third party during edition
308 return self.reload_record(record);
310 var to_delete = self.records.find(function (r) {
314 self.records.remove(to_delete);
320 * Executes an action on the view's editor bracketed by a cancellable
321 * event of the name provided.
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
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}
336 with_event: function (event_name, event, action) {
339 this.trigger(event_name + ':before', event);
341 return $.Deferred().reject({
342 message: _.str.sprintf("Event %s:before cancelled",
345 return $.when(action.call(this)).then(function () {
346 self.trigger.apply(self, [event_name + ':after']
347 .concat(_.toArray(arguments)));
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',
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;
363 widget.attrs.modifiers = JSON.stringify(modifiers);
367 handle_onwrite: function (source_record) {
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) {
375 _.bind(self.handle_onwrite_record, self, source_record)));
378 handle_onwrite_record: function (source_record, id) {
379 var record = this.records.get(id);
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);
387 return this.reload_record(record);
389 prepends_on_create: function () {
390 return this.editable() === 'top';
392 setup_events: function () {
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; })
400 if (!key) { return; }
401 var method = e.type + '_' + key.name;
402 if (!(method in self)) { return; }
407 * Saves the current record, and goes to the next one (creation or
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]
416 _next: function (next_record, options) {
417 next_record = next_record || 'succ';
419 return this.save_edition().pipe(function (saveInfo) {
420 if (saveInfo.created) {
421 return self.start_edition();
423 var record = self.records[next_record](
424 saveInfo.record, {wraparound: true});
425 return self.start_edition(record, options);
428 keyup_ENTER: function () {
431 keyup_ESCAPE: function () {
432 return this.cancel_edition();
435 * Gets the selection range (start, end) for the provided element,
436 * returns ``null`` if it can't get a range.
440 _text_selection_range: function (el) {
443 selectionStart = el.selectionStart;
445 // radio or checkbox throw on selectionStart access
448 if (selectionStart !== undefined) {
450 start: selectionStart,
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) {
460 // Element without selection ranges (select, div/@contenteditable)
463 _text_cursor: function (el) {
464 var selection = this._text_selection_range(el);
468 if (selection.start !== selection.end) {
469 return {position: null, collapsed: false};
471 return {position: selection.start, collapsed: true};
474 * Checks if the cursor is at the start of the provided el
476 * @param {HTMLInputElement | HTMLTextAreaElement}
480 _at_start: function (cursor, el) {
481 return cursor.collapsed && (cursor.position === 0);
484 * Checks if the cursor is at the end of the provided el
486 * @param {HTMLInputElement | HTMLTextAreaElement}
490 _at_end: function (cursor, el) {
491 return cursor.collapsed && (cursor.position === el.value.length);
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
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(); }
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});
513 keydown_UP: function (e) {
515 return this._key_move_record(e, 'pred', function (el, cursor) {
516 return self._at_start(cursor, el);
519 keydown_DOWN: function (e) {
521 return this._key_move_record(e, 'succ', function (el, cursor) {
522 return self._at_end(cursor, el);
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(); }
533 var fields_order = this.editor.form.fields_order;
534 var field_index = _(fields_order).indexOf(source_field);
536 // Look for the closest visible form field to the left
537 var fields = this.editor.form.fields;
540 if (--field_index < 0) { return $.when(); }
542 field = fields[fields_order[field_index]];
543 } while (!field.$element.is(':visible'));
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(); }
557 var fields_order = this.editor.form.fields_order;
558 var field_index = _(fields_order).indexOf(source_field);
560 var fields = this.editor.form.fields;
563 if (++field_index >= fields_order.length) { return $.when(); }
565 field = fields[fields_order[field_index]];
566 } while (!field.$element.is(':visible'));
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'); })
578 // tabbed from last field in form
579 if (last_field && last_field.$element.has(e.target).length) {
587 instance.web.list.Editor = instance.web.Widget.extend({
589 * @constructs instance.web.list.Editor
590 * @extends instance.web.Widget
592 * Adapter between listview and formview for editable-listview purposes
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]
599 init: function (parent, options) {
601 this.options = options || {};
602 _.defaults(this.options, {
603 formView: instance.web.FormView,
604 delegate: this.getParent()
606 this.delegate = this.options.delegate;
610 this.form = new (this.options.formView)(
611 this, this.delegate.dataset, false, {
612 initial_mode: 'edit',
613 disable_autofocus: true,
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);
627 _validate_view: function (edition_view) {
629 throw new Error("editor delegate's #edition_view must return "
630 + "a view descriptor");
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" +
637 if (!(arch.tag === "form")) {
638 throw new Error("Editor delegate's #edition_view must have a" +
639 " 'form' root node");
641 if (!(arch.attrs && arch.attrs.version === "7.0")) {
642 throw new Error("Editor delegate's #edition_view must be a" +
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" +
656 * @param {String} [state] either ``new`` or ``edit``
659 is_editing: function (state) {
664 case null: case undefined:
666 case 'new': return !this.record.id;
667 case 'edit': return !!this.record.id;
669 throw new Error("is_editing's state filter must be either `new` or" +
670 " `edit` if provided");
672 _focus_setup: function (focus_field) {
673 var form = this.form;
676 // If a field to focus was specified
678 // Is actually in the form
679 && (field = form.fields[focus_field])
681 && field.$element.is(':visible')) {
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')) {
693 // Stop as soon as a field got focused
694 return field.focus() !== false;
697 edit: function (record, configureField, options) {
698 // TODO: specify sequence of edit calls
700 var form = self.form;
702 ? form.on_record_loaded(_.extend({}, record))
703 : form.load_defaults();
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);
712 self._focus_setup(options && options.focus_field);
719 .do_save(null, this.delegate.prepends_on_create())
720 .pipe(function (result) {
721 var created = result.created && !self.record.id;
723 self.record.id = result.result;
725 return self.cancel();
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();
733 var record = this.record;
736 return $.when(record);
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)
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);
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')
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
767 * @param {Record} record the record to get a row for
768 * @return {jQuery|null}
770 get_row_for: function (record) {
772 var $row = this.$current.children('[data-id=' + record.get('id') + ']');