summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js
diff options
context:
space:
mode:
authorChristophe Simonis <chs@odoo.com>2019-05-13 15:26:12 +0200
committerChristophe Simonis <chs@odoo.com>2019-05-13 15:26:12 +0200
commit8dd5a85267b522ba569ab9ed44901f95fffbeb4f (patch)
treeddb62d1e83e1b810b96c05f6819ca66f8d6b4955 /addons/web/static/src/js
parent15716642e1975b4f2f29916011a4be29804cda88 (diff)
parent60e71302a38b36214efec1213882f573e3ea453c (diff)
[MERGE] forward port branch saas-12.3 up to 60e71302a38
Diffstat (limited to 'addons/web/static/src/js')
-rw-r--r--addons/web/static/src/js/chrome/abstract_web_client.js14
-rw-r--r--addons/web/static/src/js/core/dom.js21
-rw-r--r--addons/web/static/src/js/fields/basic_fields.js15
-rw-r--r--addons/web/static/src/js/libs/bootstrap.js6
-rw-r--r--addons/web/static/src/js/public/public_root.js7
-rw-r--r--addons/web/static/src/js/tools/debug_manager.js2
-rw-r--r--addons/web/static/src/js/views/abstract_controller.js1
-rw-r--r--addons/web/static/src/js/views/basic/basic_model.js13
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_controller.js4
-rw-r--r--addons/web/static/src/js/views/calendar/calendar_model.js7
-rw-r--r--addons/web/static/src/js/views/control_panel/control_panel_renderer.js10
-rw-r--r--addons/web/static/src/js/views/list/list_controller.js3
-rw-r--r--addons/web/static/src/js/views/list/list_editable_renderer.js33
-rw-r--r--addons/web/static/src/js/views/list/list_renderer.js60
-rw-r--r--addons/web/static/src/js/widgets/date_picker.js46
15 files changed, 184 insertions, 58 deletions
diff --git a/addons/web/static/src/js/chrome/abstract_web_client.js b/addons/web/static/src/js/chrome/abstract_web_client.js
index 9566dbda850..6b1a6249962 100644
--- a/addons/web/static/src/js/chrome/abstract_web_client.js
+++ b/addons/web/static/src/js/chrome/abstract_web_client.js
@@ -33,7 +33,9 @@ var qweb = core.qweb;
var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMixin, {
dependencies: ['notification'],
- events: _.extend(KeyboardNavigationMixin.events, {}),
+ events: _.extend({}, KeyboardNavigationMixin.events, {
+ 'click .o_search_options .dropdown-menu': '_onClickDropDownMenu',
+ }),
custom_events: {
clear_uncommitted_changes: function (e) {
this.clear_uncommitted_changes().then(e.data.callback);
@@ -356,6 +358,16 @@ var AbstractWebClient = Widget.extend(ServiceProviderMixin, KeyboardNavigationMi
//--------------------------------------------------------------------------
/**
+ * When clicking inside a dropdown to modify search options
+ * prevents the bootstrap dropdown to close on itself
+ *
+ * @private
+ * @param {Event} ev
+ */
+ _onClickDropDownMenu: function (ev) {
+ ev.stopPropagation();
+ },
+ /**
* Whenever the connection is lost, we need to notify the user.
*
* @private
diff --git a/addons/web/static/src/js/core/dom.js b/addons/web/static/src/js/core/dom.js
index 81211788246..7be17700f98 100644
--- a/addons/web/static/src/js/core/dom.js
+++ b/addons/web/static/src/js/core/dom.js
@@ -153,10 +153,27 @@ var dom = {
*
* @param {jQuery} $from - the jQuery element(s) from which to search
* @param {string} selector - the CSS selector to match
+ * @param {boolean} [addBack=false] - whether or not the $from element
+ * should be considered in the results
* @returns {jQuery}
*/
- cssFind: function ($from, selector) {
- return $from.find('*').filter(selector);
+ cssFind: function ($from, selector, addBack) {
+ var $results;
+
+ // No way to correctly parse a complex jQuery selector but having no
+ // spaces should be a good-enough condition to use a simple find
+ var multiParts = selector.indexOf(' ') >= 0;
+ if (multiParts) {
+ $results = $from.find('*').filter(selector);
+ } else {
+ $results = $from.find(selector);
+ }
+
+ if (addBack && $from.is(selector)) {
+ $results = $results.add($from);
+ }
+
+ return $results;
},
/**
* Detaches widgets from the DOM and performs their on_detach_callback()
diff --git a/addons/web/static/src/js/fields/basic_fields.js b/addons/web/static/src/js/fields/basic_fields.js
index 2330307b7a9..713ade2a989 100644
--- a/addons/web/static/src/js/fields/basic_fields.js
+++ b/addons/web/static/src/js/fields/basic_fields.js
@@ -808,7 +808,7 @@ var FieldBoolean = AbstractField.extend({
* @returns {jQuery} the focusable checkbox input
*/
getFocusableElement: function () {
- return this.$input || $();
+ return this.mode === 'readonly' ? $() : this.$input;
},
/**
* A boolean field is always set since false is a valid value.
@@ -1042,7 +1042,13 @@ var FieldFloatToggle = AbstractField.extend({
* @returns {float} The current formatted value
*/
_getDisplayedValue: function () {
- return parseFloat(this._formatValue(this.value));
+ // this.value is a plain float
+ // Matches what is in Database
+ var usrFormatValue = this._formatValue(this.value);
+ // usrFormatValue is string
+ // contains a float represented in a user specific format
+ // the float is the fraction by [this.factor] of this.value
+ return field_utils.parse['float'](usrFormatValue);
},
/**
* Formats the HTML input tag for edit mode and stores selection status.
@@ -1069,7 +1075,7 @@ var FieldFloatToggle = AbstractField.extend({
* one is not in the range, the next value of the closest one will be chosen.
*
* @private
- * @returns {number} The next formatted value in the range
+ * @returns {number} The next value in the range
*/
_nextValue: function () {
var range = this.nodeOptions.range;
@@ -1098,7 +1104,8 @@ var FieldFloatToggle = AbstractField.extend({
if (this.mode === 'edit') {
ev.stopPropagation(); // only stop propagation in edit mode
var next_val = this._nextValue();
- this._setValue(next_val.toString()); // will be parsed in _setValue
+ next_val = field_utils.format['float'](next_val);
+ this._setValue(next_val); // will be parsed in _setValue
}
},
diff --git a/addons/web/static/src/js/libs/bootstrap.js b/addons/web/static/src/js/libs/bootstrap.js
index 9db99815009..b91728b22d1 100644
--- a/addons/web/static/src/js/libs/bootstrap.js
+++ b/addons/web/static/src/js/libs/bootstrap.js
@@ -6,7 +6,7 @@ odoo.define('web.bootstrap.extensions', function () {
* patching in place.
*/
-/* Bootstrap defaults overwrite */
+/* Bootstrap tooltip defaults overwrite */
$.fn.tooltip.Constructor.Default.placement = 'auto';
$.fn.tooltip.Constructor.Default.fallbackPlacement = ['bottom', 'right', 'left', 'top'];
$.fn.tooltip.Constructor.Default.html = true;
@@ -24,4 +24,8 @@ $.fn.tooltip.Constructor.prototype.show = function () {
return bootstrapShowFunction.call(this);
};
+
+/* Bootstrap popover defaults overwrite */
+$.fn.popover.Constructor.Default.sanitize = false;
+
});
diff --git a/addons/web/static/src/js/public/public_root.js b/addons/web/static/src/js/public/public_root.js
index 2c91c795df3..31b39e97abb 100644
--- a/addons/web/static/src/js/public/public_root.js
+++ b/addons/web/static/src/js/public/public_root.js
@@ -2,6 +2,7 @@ odoo.define('web.public.root', function (require) {
'use strict';
var ajax = require('web.ajax');
+var dom = require('web.dom');
var ServiceProviderMixin = require('web.ServiceProviderMixin');
var session = require('web.session');
var utils = require('web.utils');
@@ -199,7 +200,7 @@ var PublicRoot = publicWidget.RootWidget.extend(ServiceProviderMixin, {
var defs = _.map(this._getPublicWidgetsRegistry(options), function (PublicWidget) {
var selector = PublicWidget.prototype.selector || '';
- var $target = $from.find(selector).addBack(selector);
+ var $target = dom.cssFind($from, selector, true);
var defs = _.map($target, function (el) {
var widget = new PublicWidget(self, options);
@@ -293,8 +294,8 @@ var PublicRoot = publicWidget.RootWidget.extend(ServiceProviderMixin, {
var $buttons = $(ev.currentTarget).find('button[type="submit"], a.a-submit');
_.each($buttons, function (btn) {
var $btn = $(btn);
- $btn.attr('data-loading-text', '<i class="fa fa-spinner fa-spin"></i> ' + $(btn).text());
- $btn.button('loading');
+ $btn.html('<i class="fa fa-spinner fa-spin"></i> ' + $btn.text());
+ $btn.prop('disabled', true);
});
},
/**
diff --git a/addons/web/static/src/js/tools/debug_manager.js b/addons/web/static/src/js/tools/debug_manager.js
index 54e98bbb87e..e5eff8d7fc1 100644
--- a/addons/web/static/src/js/tools/debug_manager.js
+++ b/addons/web/static/src/js/tools/debug_manager.js
@@ -436,6 +436,7 @@ DebugManager.include({
var fields = state.fields;
var fieldsInfo = state.fieldsInfo.form;
var fieldNamesInView = state.getFieldNames();
+ var fieldNamesOnlyOnView = ['message_attachment_count'];
var fieldsValues = state.data;
var modifierDatas = {};
_.each(fieldNamesInView, function (fieldName) {
@@ -444,6 +445,7 @@ DebugManager.include({
});
});
this.fields = _.chain(fieldNamesInView)
+ .difference(fieldNamesOnlyOnView)
.map(function (fieldName) {
var modifierData = modifierDatas[fieldName];
var invisibleOrReadOnly;
diff --git a/addons/web/static/src/js/views/abstract_controller.js b/addons/web/static/src/js/views/abstract_controller.js
index c7a80864a15..607f49c8031 100644
--- a/addons/web/static/src/js/views/abstract_controller.js
+++ b/addons/web/static/src/js/views/abstract_controller.js
@@ -495,7 +495,6 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
additional_context: _.extend({}, data.context)
});
}
- return false;
},
/**
* FIXME: this logic should be rethought
diff --git a/addons/web/static/src/js/views/basic/basic_model.js b/addons/web/static/src/js/views/basic/basic_model.js
index 6aa884bbacf..c87a6aca455 100644
--- a/addons/web/static/src/js/views/basic/basic_model.js
+++ b/addons/web/static/src/js/views/basic/basic_model.js
@@ -564,9 +564,16 @@ var BasicModel = AbstractModel.extend({
}
} else if (field.type === 'one2many' || field.type === 'many2many') {
if (options.raw) {
- relDataPoint = this.localData[data[fieldName]];
- relDataPoint = this._applyX2ManyOperations(relDataPoint);
- data[fieldName] = relDataPoint.res_ids;
+ if (typeof data[fieldName] === 'string') {
+ relDataPoint = this.localData[data[fieldName]];
+ relDataPoint = this._applyX2ManyOperations(relDataPoint);
+ data[fieldName] = relDataPoint.res_ids;
+ } else {
+ // no datapoint has been created yet (because the loading of relational
+ // data has been batched, and hasn't started yet), so the value is still
+ // the list of ids in the relation
+ data[fieldName] = data[fieldName] || [];
+ }
} else {
data[fieldName] = this.get(data[fieldName]) || [];
}
diff --git a/addons/web/static/src/js/views/calendar/calendar_controller.js b/addons/web/static/src/js/views/calendar/calendar_controller.js
index dda3978635b..61a966972aa 100644
--- a/addons/web/static/src/js/views/calendar/calendar_controller.js
+++ b/addons/web/static/src/js/views/calendar/calendar_controller.js
@@ -204,7 +204,9 @@ var CalendarController = AbstractController.extend({
* @param {OdooEvent} event
*/
_onDropRecord: function (event) {
- this._updateRecord(event.data);
+ this._updateRecord(_.extend({}, event.data, {
+ 'drop': true,
+ }));
},
/**
* @private
diff --git a/addons/web/static/src/js/views/calendar/calendar_model.js b/addons/web/static/src/js/views/calendar/calendar_model.js
index 1bcf4fbb298..0ad1b61e4d7 100644
--- a/addons/web/static/src/js/views/calendar/calendar_model.js
+++ b/addons/web/static/src/js/views/calendar/calendar_model.js
@@ -60,10 +60,11 @@ return AbstractModel.extend({
end.add(-1, 'days');
}
+ var isDateEvent = this.fields[this.mapping.date_start].type === 'date';
// An "allDay" event without the "all_day" option is not considered
// as a 24h day. It's just a part of the day (by default: 7h-19h).
if (event.allDay) {
- if (!this.mapping.all_day) {
+ if (!this.mapping.all_day && !isDateEvent) {
if (event.r_start) {
start.hours(event.r_start.hours())
.minutes(event.r_start.minutes())
@@ -104,7 +105,9 @@ return AbstractModel.extend({
}
if (this.mapping.date_delay) {
- data[this.mapping.date_delay] = (end.diff(start) <= 0 ? end.endOf('day').diff(start) : end.diff(start)) / 1000 / 3600;
+ if (this.data.scale !== 'month' || (this.data.scale === 'month' && !event.drop)) {
+ data[this.mapping.date_delay] = (end.diff(start) <= 0 ? end.endOf('day').diff(start) : end.diff(start)) / 1000 / 3600;
+ }
}
return data;
diff --git a/addons/web/static/src/js/views/control_panel/control_panel_renderer.js b/addons/web/static/src/js/views/control_panel/control_panel_renderer.js
index a1da2925eb6..5ce53b20806 100644
--- a/addons/web/static/src/js/views/control_panel/control_panel_renderer.js
+++ b/addons/web/static/src/js/views/control_panel/control_panel_renderer.js
@@ -18,7 +18,6 @@ var ControlPanelRenderer = Renderer.extend({
get_action_info: '_onGetActionInfo',
},
events: _.extend({}, Renderer.prototype.events, {
- 'click.bs.dropdown .o_search_options .dropdown-menu': '_onDropdownClicked',
'click .o_searchview_more': '_onMore',
}),
@@ -368,15 +367,6 @@ var ControlPanelRenderer = Renderer.extend({
//--------------------------------------------------------------------------
/**
- * Prevent the search dropdowns from closing when clicking inside them.
- *
- * @private
- * @param {MouseEvent} ev
- */
- _onDropdownClicked: function (ev) {
- ev.stopPropagation();
- },
- /**
* Toggle the search menus visibility.
*
* @private
diff --git a/addons/web/static/src/js/views/list/list_controller.js b/addons/web/static/src/js/views/list/list_controller.js
index 10d3c0d54c5..ffeef454716 100644
--- a/addons/web/static/src/js/views/list/list_controller.js
+++ b/addons/web/static/src/js/views/list/list_controller.js
@@ -466,7 +466,8 @@ var ListController = BasicController.extend({
_update: function () {
return this._super.apply(this, arguments)
.then(this._toggleSidebar.bind(this))
- .then(this._toggleCreateButton.bind(this));
+ .then(this._toggleCreateButton.bind(this))
+ .then(this._updateButtons.bind(this, 'readonly'));
},
/**
* This helper simply makes sure that the control panel buttons matches the
diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js
index c68f5ab573a..aaf073eeea0 100644
--- a/addons/web/static/src/js/views/list/list_editable_renderer.js
+++ b/addons/web/static/src/js/views/list/list_editable_renderer.js
@@ -162,7 +162,7 @@ ListRenderer.include({
* This is a specialized version of confirmChange, meant to be called when
* the change may have affected more than one line (so, for example, an
* onchange which add/remove a few lines in a x2many. This does not occur
- * in a normal list view)
+ * in a normal list view).
*
* The update is more difficult when other rows could have been changed. We
* need to potentially remove some lines, add some other lines, update some
@@ -172,6 +172,9 @@ ListRenderer.include({
* except the 'main' one (the row which caused the update), then rerender
* every new row and add them before/after the main one.
*
+ * Note that this function assumes that the list isn't grouped, which is
+ * fine as it's never the case for x2many lists.
+ *
* @param {Object} state
* @param {string} id
* @param {string[]} fields
@@ -226,18 +229,17 @@ ListRenderer.include({
}
}
- // remove all rows except the one being edited, and insert rows
- // of the re-rendered body before and after it
+ // remove all data rows except the one being edited, and insert
+ // data rows of the re-rendered body before and after it
var $editedRow = self._getRow(id);
- $editedRow.nextAll().remove();
- $editedRow.prevAll().remove();
+ $editedRow.nextAll('.o_data_row').remove();
+ $editedRow.prevAll('.o_data_row').remove();
var $newRow = $newBody.find('.o_data_row[data-id="' + id + '"]');
- var $tbody = self.$('tbody');
- $newRow.prevAll().each(function (i, prevRow) {
- $tbody.prepend($(prevRow));
+ $newRow.prevAll('.o_data_row').get().reverse().forEach(function (row) {
+ $(row).insertBefore($editedRow);
});
- $newRow.nextAll().each(function (i, nextRow) {
- $tbody.append($(nextRow));
+ $newRow.nextAll('.o_data_row').get().reverse().forEach(function (row) {
+ $(row).insertAfter($editedRow);
});
if (self.currentRow !== null) {
@@ -309,7 +311,16 @@ ListRenderer.include({
if (this.state.count >= 4) {
$row.remove();
} else {
- $row.replaceWith(this._renderEmptyRow());
+ // we want to always keep at least 4 (possibly empty) rows
+ var $emptyRow = this._renderEmptyRow();
+ $row.replaceWith($emptyRow);
+ if (this.editable === "top") {
+ // move the empty row we just inserted after data rows
+ var $lastDataRow = this.$('.o_data_row:last');
+ if ($lastDataRow.length) {
+ $emptyRow.insertAfter($lastDataRow);
+ }
+ }
}
},
/**
diff --git a/addons/web/static/src/js/views/list/list_renderer.js b/addons/web/static/src/js/views/list/list_renderer.js
index 4bf0ac82941..0d04fd69585 100644
--- a/addons/web/static/src/js/views/list/list_renderer.js
+++ b/addons/web/static/src/js/views/list/list_renderer.js
@@ -441,7 +441,6 @@ var ListRenderer = BasicRenderer.extend({
* @returns {jQuery} a <button> element
*/
_renderGroupButton: function (list, node) {
- var self = this;
var $button = viewUtils.renderButtonFromNode(node, {
extraClass: node.attrs.icon ? 'o_icon_button' : undefined,
textAsTitle: !!node.attrs.icon,
@@ -449,20 +448,10 @@ var ListRenderer = BasicRenderer.extend({
this._handleAttributes($button, node);
this._registerModifiers(node, list.groupData, $button);
- // TODO this should be moved to a handler
- $button.on("click", function (e) {
- e.stopPropagation();
- if (node.attrs.type === 'edit') {
- self.trigger_up('group_edit_button_clicked', {
- record: list.groupData,
- });
- } else {
- self.trigger_up('button_clicked', {
- attrs: node.attrs,
- record: list.groupData,
- });
- }
- });
+ // TODO this should be moved to event handlers
+ $button.on("click", this._onGroupButtonClicked.bind(this, list.groupData, node));
+ $button.on("keydown", this._onGroupButtonKeydown.bind(this));
+
return $button;
},
/**
@@ -889,6 +878,40 @@ var ListRenderer = BasicRenderer.extend({
//--------------------------------------------------------------------------
/**
+ * @private
+ * @param {Object} record a record dataPoint on which the button applies
+ * @param {Object} node arch node of the button
+ * @param {Object} node.attrs the attrs of the button in the arch
+ * @param {jQueryEvent} ev
+ */
+ _onGroupButtonClicked: function (record, node, ev) {
+ ev.stopPropagation();
+ if (node.attrs.type === 'edit') {
+ this.trigger_up('group_edit_button_clicked', {
+ record: record,
+ });
+ } else {
+ this.trigger_up('button_clicked', {
+ attrs: node.attrs,
+ record: record,
+ });
+ }
+ },
+ /**
+ * If the user presses ENTER on a group header button, we want to execute
+ * the button action. This is done automatically as the click handler is
+ * called. However, we have to stop the propagation of the event to prevent
+ * another handler from closing the group (see _onKeyDown).
+ *
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onGroupButtonKeydown: function (ev) {
+ if (ev.keyCode === $.ui.keyCode.ENTER) {
+ ev.stopPropagation();
+ }
+ },
+ /**
* Manages the keyboard events on the list. If the list is not editable, when the user navigates to
* a cell using the keyboard, if he presses enter, enter the model represented by the line
*
@@ -943,8 +966,11 @@ var ListRenderer = BasicRenderer.extend({
break;
}
if ($futureCell) {
- // If the cell contains activable elements, focus them instead
- var $activables = $futureCell.find(':focusable');
+ // If the cell contains activable elements, focus them instead (except if it is in a
+ // group header, in which case we want to activate the whole header, so that we can
+ // open/close it with RIGHT/LEFT keystrokes)
+ var isInGroupHeader = $futureCell.closest('tr').hasClass('o_group_header');
+ var $activables = !isInGroupHeader && $futureCell.find(':focusable');
if ($activables.length) {
$activables[0].focus();
} else {
diff --git a/addons/web/static/src/js/widgets/date_picker.js b/addons/web/static/src/js/widgets/date_picker.js
index 4ae0e1833b6..3206ef7b47a 100644
--- a/addons/web/static/src/js/widgets/date_picker.js
+++ b/addons/web/static/src/js/widgets/date_picker.js
@@ -14,7 +14,9 @@ var DateWidget = Widget.extend({
events: {
'change.datetimepicker': 'changeDatetime',
'change .o_datepicker_input': 'changeDatetime',
+ 'click input': '_onInputClicked',
'input input': '_onInput',
+ 'keydown': '_onKeydown',
'show.datetimepicker': '_onDateTimePickerShow',
'hide.datetimepicker': '_onDateTimePickerHide',
},
@@ -50,10 +52,13 @@ var DateWidget = Widget.extend({
},
widgetParent: 'body',
keyBinds: null,
- allowInputToggle: true,
}, options || {});
this.__libInput = 0;
+ // tempusdominus doesn't offer any elegant way to check whether the
+ // datepicker is open or not, so we have to listen to hide/show events
+ // and manually keep track of the 'open' state
+ this.__isOpen = false;
},
/**
* @override
@@ -238,6 +243,7 @@ var DateWidget = Widget.extend({
* @private
*/
_onDateTimePickerHide: function () {
+ this.__isOpen = false;
if (this._onScroll) {
window.removeEventListener('scroll', this._onScroll, true);
}
@@ -250,6 +256,7 @@ var DateWidget = Widget.extend({
* @private
*/
_onDateTimePickerShow: function () {
+ this.__isOpen = true;
if (this.$input.val().length !== 0 && this.isValid()) {
this.$input.select();
}
@@ -264,6 +271,34 @@ var DateWidget = Widget.extend({
window.addEventListener('scroll', this._onScroll, true);
},
/**
+ * @private
+ * @param {KeyEvent} ev
+ */
+ _onKeydown: function (ev) {
+ if (ev.which === $.ui.keyCode.ESCAPE) {
+ this.__libInput++;
+ this.$el.datetimepicker('hide');
+ this.__libInput--;
+ }
+ },
+ /**
+ * @private
+ * @param {KeyEvent} ev
+ */
+ _onKeydown: function (ev) {
+ if (ev.which === $.ui.keyCode.ESCAPE) {
+ if (this.__isOpen) {
+ // we don't want any other effects than closing the datepicker,
+ // like leaving the edition of a row in editable list view
+ ev.stopImmediatePropagation();
+ this.__libInput++;
+ this.$el.datetimepicker('hide');
+ this.__libInput--;
+ this.focus();
+ }
+ }
+ },
+ /**
* Prevents 'input' events triggered by the library to bubble up, as they
* might have unwanted effects (like triggering 'field_changed' events in
* the context of field widgets)
@@ -276,6 +311,15 @@ var DateWidget = Widget.extend({
ev.stopImmediatePropagation();
}
},
+ /**
+ * @private
+ */
+ _onInputClicked: function () {
+ this.__libInput++;
+ this.$el.datetimepicker('toggle');
+ this.__libInput--;
+ this.focus();
+ },
});
var DateTimeWidget = DateWidget.extend({