[IMP] web: export improvements
* remove center buttons, corresponding features either moved to fields list (handle / bin on exported, etc...) or removed entirely (e.g. clear) * replace XLS to XLSX format, increases rows# to 1048579 * add FAYT filtering of available fields * sort available fields alphabetically (CI) always * change export templates UI: remove buttons TaskID: 1910953 closes odoo/odoo#32339 Signed-off-by: Xavier Morel (xmo) <xmo@odoo.com>
This commit is contained in:
committed by
Xavier Morel
parent
b6193a6b8d
commit
22b598bc1b
@@ -39,7 +39,7 @@ from odoo.modules import get_module_path, get_resource_path
|
||||
from odoo.tools import image_process, topological_sort, html_escape, pycompat, ustr, apply_inheritance_specs
|
||||
from odoo.tools.mimetypes import guess_mimetype
|
||||
from odoo.tools.translate import _
|
||||
from odoo.tools.misc import str2bool, xlwt, file_open
|
||||
from odoo.tools.misc import str2bool, xlsxwriter, file_open
|
||||
from odoo.tools.safe_eval import safe_eval
|
||||
from odoo import http, tools
|
||||
from odoo.http import content_disposition, dispatch_rpc, request, Response
|
||||
@@ -1441,7 +1441,7 @@ class Export(http.Controller):
|
||||
:rtype: [(str, str)]
|
||||
"""
|
||||
return [
|
||||
{'tag': 'xls', 'label': 'Excel', 'error': None if xlwt else "XLWT 1.3.0 required"},
|
||||
{'tag': 'xlsx', 'label': 'XLSX', 'error': None if xlsxwriter else "XlsxWriter 0.9.3 required"},
|
||||
{'tag': 'csv', 'label': 'CSV'},
|
||||
]
|
||||
|
||||
@@ -1455,23 +1455,21 @@ class Export(http.Controller):
|
||||
import_compat=True, parent_field_type=None,
|
||||
parent_field=None, exclude=None):
|
||||
|
||||
if import_compat and parent_field_type in ['many2one', 'many2many']:
|
||||
fields = self.fields_get(model)
|
||||
fields = {k: v for k, v in fields.items() if k in ['id', 'name']}
|
||||
fields = self.fields_get(model)
|
||||
if import_compat:
|
||||
if parent_field_type in ['many2one', 'many2many']:
|
||||
fields = {'id': fields['id'], 'name': fields['name']}
|
||||
else:
|
||||
fields = self.fields_get(model)
|
||||
fields['.id'] = {**fields['id']}
|
||||
|
||||
if not import_compat:
|
||||
fields['.id'] = fields.pop('id', {'string': 'ID'})
|
||||
else:
|
||||
fields['id']['string'] = _('External ID')
|
||||
fields['id']['string'] = _('External ID')
|
||||
|
||||
if parent_field:
|
||||
parent_field['string'] = _('External ID')
|
||||
fields['id'] = parent_field
|
||||
|
||||
fields_sequence = sorted(fields.items(),
|
||||
key=lambda field: (field[0] not in ['id', '.id', 'display_name', 'name'], odoo.tools.ustr(field[1].get('string', ''))))
|
||||
key=lambda field: odoo.tools.ustr(field[1].get('string', '').lower()))
|
||||
|
||||
records = []
|
||||
for field_name, field in fields_sequence:
|
||||
@@ -1487,12 +1485,13 @@ class Export(http.Controller):
|
||||
continue
|
||||
|
||||
id = prefix + (prefix and '/'or '') + field_name
|
||||
val = id
|
||||
if field_name == 'name' and import_compat and parent_field_type in ['many2one', 'many2many']:
|
||||
# Add name field when expand m2o and m2m fields in import-compatible mode
|
||||
id = prefix
|
||||
val = prefix
|
||||
name = parent_name + (parent_name and '/' or '') + field['string']
|
||||
record = {'id': id, 'string': name,
|
||||
'value': id, 'children': False,
|
||||
'value': val, 'children': False,
|
||||
'field_type': field.get('type'),
|
||||
'required': field.get('required'),
|
||||
'relation_field': field.get('relation_field')}
|
||||
@@ -1658,63 +1657,61 @@ class CSVExport(ExportFormat, http.Controller):
|
||||
|
||||
class ExcelExport(ExportFormat, http.Controller):
|
||||
|
||||
@http.route('/web/export/xls', type='http', auth="user")
|
||||
@http.route('/web/export/xlsx', type='http', auth="user")
|
||||
@serialize_exception
|
||||
def index(self, data, token):
|
||||
return self.base(data, token)
|
||||
|
||||
@property
|
||||
def content_type(self):
|
||||
return 'application/vnd.ms-excel'
|
||||
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
|
||||
def filename(self, base):
|
||||
return base + '.xls'
|
||||
return base + '.xlsx'
|
||||
|
||||
def from_data(self, fields, rows):
|
||||
if len(rows) > 65535:
|
||||
raise UserError(_('There are too many rows (%s rows, limit: 65535) to export as Excel 97-2003 (.xls) format. Consider splitting the export.') % len(rows))
|
||||
|
||||
workbook = xlwt.Workbook()
|
||||
worksheet = workbook.add_sheet('Sheet 1')
|
||||
output = io.BytesIO()
|
||||
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
|
||||
worksheet = workbook.add_worksheet()
|
||||
if len(rows) > worksheet.xls_rowmax:
|
||||
raise UserError(_('There are too many rows (%s rows, limit: %s) to export as Excel 2007-2013 (.xlsx) format. Consider splitting the export.') % (len(rows), worksheet.xls_rowmax))
|
||||
|
||||
for i, fieldname in enumerate(fields):
|
||||
worksheet.write(0, i, fieldname)
|
||||
worksheet.col(i).width = 8000 # around 220 pixels
|
||||
worksheet.set_column(0, len(fields)-1, 30) # around 220 pixels
|
||||
|
||||
base_style = xlwt.easyxf('align: wrap yes')
|
||||
date_style = xlwt.easyxf('align: wrap yes', num_format_str='YYYY-MM-DD')
|
||||
datetime_style = xlwt.easyxf('align: wrap yes', num_format_str='YYYY-MM-DD HH:mm:SS')
|
||||
base_style = workbook.add_format({'text_wrap': True})
|
||||
date_style = workbook.add_format({'text_wrap': True, 'num_format': 'yyyy-mm-dd'})
|
||||
datetime_style = workbook.add_format({'text_wrap': True, 'num_format': 'yyyy-mm-dd hh:mm:ss'})
|
||||
|
||||
for row_index, row in enumerate(rows):
|
||||
for cell_index, cell_value in enumerate(row):
|
||||
cell_style = base_style
|
||||
|
||||
if isinstance(cell_value, bytes) and not isinstance(cell_value, str):
|
||||
# because xls uses raw export, we can get a bytes object
|
||||
# here. xlwt does not support bytes values in Python 3 ->
|
||||
# assume this is base64 and decode to a string, if this
|
||||
# fails note that you can't export
|
||||
if isinstance(cell_value, bytes):
|
||||
try:
|
||||
# because xlsx uses raw export, we can get a bytes object
|
||||
# here. xlsxwriter does not support bytes values in Python 3 ->
|
||||
# assume this is base64 and decode to a string, if this
|
||||
# fails note that you can't export
|
||||
cell_value = pycompat.to_text(cell_value)
|
||||
except UnicodeDecodeError:
|
||||
raise UserError(_("Binary fields can not be exported to Excel unless their content is base64-encoded. That does not seem to be the case for %s.") % fields[cell_index])
|
||||
|
||||
if isinstance(cell_value, str):
|
||||
cell_value = re.sub("\r", " ", pycompat.to_text(cell_value))
|
||||
# Excel supports a maximum of 32767 characters in each cell:
|
||||
cell_value = cell_value[:32767]
|
||||
if len(cell_value) > worksheet.xls_strmax:
|
||||
cell_value = _("The content of this cell is too long for an XLSX file (more than %s characters). Please use the CSV format for this export.") % worksheet.xls_strmax
|
||||
else:
|
||||
cell_value = cell_value.replace("\r", " ")
|
||||
elif isinstance(cell_value, datetime.datetime):
|
||||
cell_style = datetime_style
|
||||
elif isinstance(cell_value, datetime.date):
|
||||
cell_style = date_style
|
||||
worksheet.write(row_index + 1, cell_index, cell_value, cell_style)
|
||||
|
||||
fp = io.BytesIO()
|
||||
workbook.save(fp)
|
||||
fp.seek(0)
|
||||
data = fp.read()
|
||||
fp.close()
|
||||
return data
|
||||
workbook.close()
|
||||
with output:
|
||||
return output.getvalue()
|
||||
|
||||
class Apps(http.Controller):
|
||||
@http.route('/apps/<app>', auth='user')
|
||||
|
||||
@@ -19,15 +19,14 @@ var DataExport = Dialog.extend({
|
||||
'click .o_add_field': '_onClickAddField',
|
||||
'click .o_delete_exported_list': '_onClickDeleteExportListBtn',
|
||||
'click .o_expand': '_onClickExpand',
|
||||
'click .o_move_down': '_onClickMovedown',
|
||||
'click .o_move_up': '_onClickMoveup',
|
||||
'click .o_remove_all_field': '_onClickRemoveAllFields',
|
||||
'click .o_remove_field': '_onClickRemoveField',
|
||||
'click .o_toggle_save_list': '_onClickSaveList',
|
||||
'click .o_save_list > button': '_onClickSaveListBtn',
|
||||
'click .o_save_list .o_save_list_btn': '_onClickSaveListBtn',
|
||||
'click .o_save_list .o_cancel_list_btn': '_resetTemplateField',
|
||||
'click .o_export_tree_item': '_onClickTreeItem',
|
||||
'dblclick .o_export_tree_item:not(.haschild)': '_onDblclickTreeItem',
|
||||
'keydown .o_export_tree_item': '_onKeydownTreeItem'
|
||||
'keydown .o_export_tree_item': '_onKeydownTreeItem',
|
||||
'keydown .o_save_list_name': '_onKeydownSaveList',
|
||||
'input .o_export_search_input': '_onSearchInput',
|
||||
},
|
||||
/**
|
||||
* @constructor
|
||||
@@ -39,7 +38,7 @@ var DataExport = Dialog.extend({
|
||||
var options = {
|
||||
title: _t("Export Data"),
|
||||
buttons: [
|
||||
{text: _t("Export To File"), click: this._exportData, classes: 'btn-primary'},
|
||||
{text: _t("Export"), click: this._exportData, classes: 'btn-primary'},
|
||||
{text: _t("Close"), close: true},
|
||||
],
|
||||
};
|
||||
@@ -65,7 +64,6 @@ var DataExport = Dialog.extend({
|
||||
this.$modal.find('.modal-content').css('height', '100%');
|
||||
|
||||
this.$fieldsList = this.$('.o_fields_list');
|
||||
this.$importCompatRadios = this.$('.o_import_compat input');
|
||||
|
||||
proms.push(this._rpc({route: '/web/export/formats'}).then(doSetupExportFormats));
|
||||
proms.push(this._onChangeCompatibleInput());
|
||||
@@ -81,29 +79,37 @@ var DataExport = Dialog.extend({
|
||||
}));
|
||||
|
||||
proms.push(this._showExportsList().then(function () {
|
||||
_.each(self.records, function (record, key) {
|
||||
if (_.contains(self.defaultExportFields, key)) {
|
||||
self._addField(key, record.string);
|
||||
}
|
||||
_.each(self.defaultExportFields, function (field) {
|
||||
var record = self.records[field];
|
||||
self._addField(record.id, record.string);
|
||||
});
|
||||
}));
|
||||
|
||||
// Bind sortable events after Dialog is open
|
||||
this.opened().then(function () {
|
||||
self.$('.o_fields_list').sortable({
|
||||
axis: 'y',
|
||||
handle: '.o_short_field',
|
||||
forcePlaceholderSize: true,
|
||||
placeholder: 'o-field-placeholder',
|
||||
update: self.proxy('_resetTemplateField'),
|
||||
});
|
||||
});
|
||||
return Promise.all(proms);
|
||||
|
||||
function doSetupExportFormats(formats) {
|
||||
var $fmts = self.$('.o_export_format');
|
||||
|
||||
_.each(formats, function (format) {
|
||||
var $radio = $('<input/>', {type: 'radio', value: format.tag, name: 'o_export_format_name'});
|
||||
var $label = $('<span/>', {html: format.label});
|
||||
var $radio = $('<input/>', {type: 'radio', value: format.tag, name: 'o_export_format_name', class: 'form-check-input', id: 'o_radio' + format.label});
|
||||
var $label = $('<label/>', {html: format.label, class: 'form-check-label', for: 'o_radio' + format.label});
|
||||
|
||||
if (format.error) {
|
||||
$radio.prop('disabled', true);
|
||||
$label.html(_.str.sprintf("%s — %s", format.label, format.error));
|
||||
}
|
||||
|
||||
var $radioButton = $('<label/>').append($radio, $label);
|
||||
$fmts.append($("<div class='radio'></div>").append($radioButton));
|
||||
$fmts.append($("<div class='radio form-check form-check-inline pl-4'></div>").append($radio, $label));
|
||||
});
|
||||
|
||||
self.$exportFormatInputs = $fmts.find('input');
|
||||
@@ -124,22 +130,27 @@ var DataExport = Dialog.extend({
|
||||
*/
|
||||
_addField: function (fieldID, label) {
|
||||
var $fieldList = this.$('.o_fields_list');
|
||||
fieldID = this.records[fieldID].value || fieldID;
|
||||
if ($fieldList.find('option[value="' + fieldID + '"]').length === 0) {
|
||||
$fieldList.append(new Option(label, fieldID));
|
||||
if (!$fieldList.find(".o_export_field[data-field_id='" + fieldID + "']").length) {
|
||||
$fieldList.append(
|
||||
$('<li>', {'class': 'o_export_field', 'data-field_id': fieldID}).append(
|
||||
$('<span>', {'class': "fa fa-arrows o_short_field mx-1"}),
|
||||
label,
|
||||
$('<span>', {'class': 'fa fa-trash m-1 pull-right o_remove_field', 'title': _t("Remove field")})
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Submit the user data and export the file
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_exportData: function () {
|
||||
var self = this;
|
||||
var exportedFields = this.$('.o_fields_list option').map(function () {
|
||||
var exportedFields = this.$('.o_export_field').map(function () {
|
||||
return {
|
||||
name: (self.records[this.value] || this).value,
|
||||
label: this.textContent || this.innerText, // DOM property is textContent, but IE8 only knows innerText
|
||||
name: $(this).data('field_id'),
|
||||
label: this.textContent
|
||||
};
|
||||
}).get();
|
||||
|
||||
@@ -147,7 +158,7 @@ var DataExport = Dialog.extend({
|
||||
Dialog.alert(this, _t("Please select fields to export..."));
|
||||
return;
|
||||
}
|
||||
if (!this.isCompatibleMode) {
|
||||
if (this.isCompatibleMode) {
|
||||
exportedFields.unshift({ name: 'id', label: _t('External ID') });
|
||||
}
|
||||
|
||||
@@ -163,7 +174,7 @@ var DataExport = Dialog.extend({
|
||||
ids: this.idsToExport,
|
||||
domain: this.domain,
|
||||
context: pyUtils.eval('contexts', [this.record.getContext()]),
|
||||
import_compat: !!this.$importCompatRadios.filter(':checked').val(),
|
||||
import_compat: this.isCompatibleMode,
|
||||
})
|
||||
},
|
||||
complete: framework.unblockUI,
|
||||
@@ -175,8 +186,8 @@ var DataExport = Dialog.extend({
|
||||
* @returns {string[]} exportFields
|
||||
*/
|
||||
_getFields: function () {
|
||||
var exportFields = this.$('.o_fields_list option').map(function () {
|
||||
return $(this).val();
|
||||
var exportFields = this.$('.o_export_field').map(function () {
|
||||
return $(this).data('field_id');
|
||||
}).get();
|
||||
if (exportFields.length === 0) {
|
||||
Dialog.alert(this, _t("Please select fields to save export list..."));
|
||||
@@ -211,7 +222,7 @@ var DataExport = Dialog.extend({
|
||||
model: model,
|
||||
prefix: prefix,
|
||||
parent_name: name,
|
||||
import_compat: !!this.$importCompatRadios.filter(':checked').val(),
|
||||
import_compat: this.isCompatibleMode,
|
||||
parent_field_type: record.field_type,
|
||||
parent_field: record.params.parent_field,
|
||||
exclude: excludeFields,
|
||||
@@ -254,6 +265,31 @@ var DataExport = Dialog.extend({
|
||||
var $el = $(el);
|
||||
$el.find('.o_tree_column').first().toggleClass('o_required', !!self.records[$el.data('id')].required);
|
||||
});
|
||||
this.$('#o-export-search-filter').val('');
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_addNewTemplate: function () {
|
||||
this.$('.o_exported_lists').addClass('d-none');
|
||||
|
||||
this.$(".o_save_list")
|
||||
.show()
|
||||
.find(".o_save_list_name")
|
||||
.val("")
|
||||
.focus();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_resetTemplateField: function () {
|
||||
this.$('.o_exported_lists_select').val("");
|
||||
this.$('.o_delete_exported_list').addClass('d-none');
|
||||
this.$('.o_exported_lists').removeClass('d-none');
|
||||
|
||||
this.$(".o_save_list")
|
||||
.hide()
|
||||
.find(".o_save_list_name").val("");
|
||||
},
|
||||
/**
|
||||
* If relational fields info is already fetched then this method is
|
||||
@@ -283,6 +319,7 @@ var DataExport = Dialog.extend({
|
||||
$child.show();
|
||||
}
|
||||
}
|
||||
this.$('#o-export-search-filter').val('');
|
||||
},
|
||||
/**
|
||||
* Fetches the saved export list for the current model
|
||||
@@ -303,9 +340,6 @@ var DataExport = Dialog.extend({
|
||||
fields: ['name'],
|
||||
domain: [['resource', '=', this.record.model]]
|
||||
}).then(function (exportList) {
|
||||
if (!exportList.length) {
|
||||
return;
|
||||
}
|
||||
self.$('.o_exported_lists').append(QWeb.render('Export.SavedList', {
|
||||
existing_exports: exportList,
|
||||
}));
|
||||
@@ -323,9 +357,10 @@ var DataExport = Dialog.extend({
|
||||
*/
|
||||
_onChangeExportList: function () {
|
||||
var self = this;
|
||||
this.$fieldsList.empty();
|
||||
var exportID = this.$('.o_exported_lists_select option:selected').val();
|
||||
if (exportID) {
|
||||
this.$('.o_delete_exported_list').toggleClass('d-none', !exportID);
|
||||
if (exportID && exportID !== 'new_template') {
|
||||
this.$('.o_fields_list').empty();
|
||||
this._rpc({
|
||||
route: '/web/export/namelist',
|
||||
params: {
|
||||
@@ -334,9 +369,11 @@ var DataExport = Dialog.extend({
|
||||
},
|
||||
}).then(function (fieldList) {
|
||||
_.each(fieldList, function (field) {
|
||||
self.$fieldsList.append(new Option(field.label, field.name));
|
||||
self._addField(field.name, field.label);
|
||||
});
|
||||
});
|
||||
} else if (exportID === 'new_template') {
|
||||
self._addNewTemplate();
|
||||
}
|
||||
},
|
||||
/**
|
||||
@@ -345,10 +382,10 @@ var DataExport = Dialog.extend({
|
||||
*/
|
||||
_onChangeCompatibleInput: function () {
|
||||
var self = this;
|
||||
this.isCompatibleMode = this.$('.o_import_compat input[value="yes"]').is(':checked');
|
||||
this.isCompatibleMode = this.$('.o_import_compat input').is(':checked');
|
||||
|
||||
this.$('.o_field_tree_structure').remove();
|
||||
|
||||
this._resetTemplateField();
|
||||
return this._rpc({
|
||||
route: '/web/export/get_fields',
|
||||
params: {
|
||||
@@ -357,40 +394,33 @@ var DataExport = Dialog.extend({
|
||||
},
|
||||
}).then(function (records) {
|
||||
var compatibleFields = _.map(records, function (record) { return record.id; });
|
||||
self.$fieldsList
|
||||
.find('option')
|
||||
.filter(function () {
|
||||
var optionField = $(this).attr('value');
|
||||
if (compatibleFields.indexOf(optionField) === -1) {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
.remove();
|
||||
self._onShowData(records);
|
||||
// In compatible mode add ID field as first field to export
|
||||
if (self.isCompatibleMode) {
|
||||
self.$('.o_fields_list').prepend(new Option(_t("External ID"), 'id'));
|
||||
}
|
||||
_.each(records, function (record) {
|
||||
if (_.contains(self.defaultExportFields, record.id)) {
|
||||
self._addField(record.id, record.string);
|
||||
}
|
||||
self.$('.o_fields_list').empty();
|
||||
|
||||
_.chain(self.$fieldsList.find('.o_export_field'))
|
||||
.map(function (field) { return $(field).data('field_id'); })
|
||||
.union(self.defaultExportFields)
|
||||
.intersection(compatibleFields)
|
||||
.each(function (field) {
|
||||
var record = _.find(records, function (rec) {
|
||||
return rec.id === field;
|
||||
});
|
||||
self._addField(record.id, record.string);
|
||||
});
|
||||
self.$('#o-export-search-filter').val('');
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Add a field to export list
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onClickAddField: function () {
|
||||
var self = this;
|
||||
this.$('.o_field_tree_structure .o_selected')
|
||||
.removeClass('o_selected')
|
||||
.each(function () {
|
||||
var $this = $(this);
|
||||
self._addField($this.data('id'), $this.children('.o_tree_column').text());
|
||||
});
|
||||
_onClickAddField: function(ev) {
|
||||
ev.stopPropagation();
|
||||
var $field = $(ev.currentTarget);
|
||||
this._resetTemplateField();
|
||||
this._addField($field.closest('.o_export_tree_item').data('id'), $field.closest('.o_tree_column').text());
|
||||
},
|
||||
/**
|
||||
* Delete selected export list item from the saved export list
|
||||
@@ -405,7 +435,6 @@ var DataExport = Dialog.extend({
|
||||
if (selectExp.val()) {
|
||||
self.exports.unlink([parseInt(selectExp.val(), 10)]);
|
||||
selectExp.remove();
|
||||
self.$fieldsList.empty();
|
||||
if (self.$('.o_exported_lists_select option').length <= 1) {
|
||||
self.$('.o_exported_lists').hide();
|
||||
}
|
||||
@@ -421,60 +450,15 @@ var DataExport = Dialog.extend({
|
||||
_onClickExpand: function (ev) {
|
||||
this._onExpandAction(this.records[$(ev.target).closest('.o_export_tree_item').data('id')]);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onClickMovedown: function () {
|
||||
var $selectedRows = this.$fieldsList.find('option:selected');
|
||||
var $nextRow = $selectedRows.last().next();
|
||||
if ($nextRow.length) {
|
||||
$nextRow.after($selectedRows.detach());
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
*/
|
||||
_onClickMoveup: function () {
|
||||
var $selectedRows = this.$fieldsList.find('option:selected');
|
||||
var $prevRow = $selectedRows.first().prev();
|
||||
if ($prevRow.length) {
|
||||
$prevRow.before($selectedRows.detach());
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Remove all fields from export field list
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onClickRemoveAllFields: function () {
|
||||
this.$fieldsList.empty();
|
||||
},
|
||||
/**
|
||||
* Remove selected field from export field list
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onClickRemoveField: function () {
|
||||
this.$fieldsList.find('option:selected').remove();
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onClickSaveList: function (ev) {
|
||||
ev.preventDefault();
|
||||
|
||||
var $saveList = this.$('.o_save_list');
|
||||
if ($saveList.is(':empty')) {
|
||||
$saveList.append(QWeb.render('Export.SaveList'));
|
||||
} else {
|
||||
if ($saveList.is(':hidden')) {
|
||||
$saveList.show();
|
||||
$saveList.find('.o_export_list_input').val('');
|
||||
} else {
|
||||
$saveList.hide();
|
||||
}
|
||||
}
|
||||
_onClickRemoveField: function (ev) {
|
||||
$(ev.currentTarget).closest('.o_export_field').remove();
|
||||
this._resetTemplateField();
|
||||
},
|
||||
/**
|
||||
* This method will create a record in 'ir.exports' model with list of
|
||||
@@ -514,11 +498,13 @@ var DataExport = Dialog.extend({
|
||||
self._showExportsList();
|
||||
}
|
||||
$select.append(new Option(value, exportListID));
|
||||
self.$('.o_exported_lists').removeClass('d-none');
|
||||
$select.val(exportListID);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
* @param ev
|
||||
*/
|
||||
_onClickTreeItem: function (ev) {
|
||||
ev.stopPropagation();
|
||||
@@ -546,7 +532,7 @@ var DataExport = Dialog.extend({
|
||||
} else if (ev.shiftKey) {
|
||||
$elem.addClass('o_selected').focus();
|
||||
} else {
|
||||
this.$('.o_selected').removeClass('o_selected')
|
||||
this.$('.o_selected').removeClass('o_selected');
|
||||
$elem.addClass('o_selected').focus();
|
||||
}
|
||||
|
||||
@@ -567,6 +553,7 @@ var DataExport = Dialog.extend({
|
||||
*/
|
||||
_onDblclickTreeItem: function (ev) {
|
||||
var self = this;
|
||||
this._resetTemplateField();
|
||||
function addElement(el) {
|
||||
self._addField(el.getAttribute('data-id'), el.querySelector('.o_tree_column').textContent);
|
||||
}
|
||||
@@ -579,11 +566,20 @@ var DataExport = Dialog.extend({
|
||||
// add field itself
|
||||
addElement(target);
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param ev
|
||||
*/
|
||||
_onKeydownSaveList: function (ev) {
|
||||
if (ev.keyCode === $.ui.keyCode.ENTER) {
|
||||
this._onClickSaveListBtn();
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handles the keyboard navigation for the fields
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
* @param ev
|
||||
*/
|
||||
_onKeydownTreeItem: function (ev) {
|
||||
ev.stopPropagation();
|
||||
@@ -636,6 +632,41 @@ var DataExport = Dialog.extend({
|
||||
break;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Search fields from a field list.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_onSearchInput: function (ev) {
|
||||
var searchText = $(ev.currentTarget).val().trim().toUpperCase();
|
||||
if (!searchText) {
|
||||
this.$('.o_no_match').remove();
|
||||
this.$(".o_export_tree_item").show();
|
||||
this.$(".o_export_tree_item.haschild:not(.show) .o_export_tree_item").hide();
|
||||
return;
|
||||
}
|
||||
|
||||
var matchItems = this.$(".o_tree_column").filter(function () {
|
||||
return this.innerText.toUpperCase().indexOf(searchText) >= 0;
|
||||
}).parent();
|
||||
this.$(".o_export_tree_item").hide();
|
||||
if (matchItems.length) {
|
||||
this.$('.o_no_match').remove();
|
||||
_.each(matchItems, function (col) {
|
||||
var $col = $(col);
|
||||
$col.show();
|
||||
$col.parents('.haschild.show').show();
|
||||
if (!$col.parent().hasClass('show') && !$col.parent().hasClass('o_field_tree_structure')) {
|
||||
$col.hide();
|
||||
}
|
||||
});
|
||||
} else if (!this.$('.o_no_match').length) {
|
||||
this.$(".o_field_tree_structure").append($("<h3/>", {
|
||||
class: 'text-center text-muted mt-5 o_no_match',
|
||||
text: _t("No match found.")
|
||||
}));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return DataExport;
|
||||
|
||||
@@ -1,90 +1,52 @@
|
||||
.o_web_client .o_export {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
min-height: 0;
|
||||
|
||||
> p, > .row {
|
||||
flex: 0 0 auto;
|
||||
padding: 20px;
|
||||
.o-export-panel {
|
||||
height: 100%;
|
||||
}
|
||||
.o_left_panel {
|
||||
padding-right: 10px;
|
||||
flex-flow: column nowrap;
|
||||
height: 100%;
|
||||
.o_left_field_panel {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
.o_export_tree_item {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
&.o_selected > .o_tree_column {
|
||||
background-color: $o-brand-odoo;
|
||||
color: white;
|
||||
}
|
||||
.o_expand_parent {
|
||||
@include o-position-absolute($top: 4px, $left: 5px);
|
||||
font-size: 10px;
|
||||
}
|
||||
.o_required {
|
||||
border-bottom: 2px solid $o-main-text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_export_panel {
|
||||
flex: 1 1 auto;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
|
||||
> div {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
justify-content: center;
|
||||
|
||||
&.o_left_panel { // Needed because of chrome
|
||||
@include o-position-absolute($top: 0, $bottom: 0, $left: 0, $right: 57%);
|
||||
.o_right_panel {
|
||||
padding-left: 10px;
|
||||
flex-flow: column nowrap;
|
||||
height: 100%;
|
||||
.o_save_list {
|
||||
display: none;
|
||||
}
|
||||
.o_right_field_panel {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
.o_short_field {
|
||||
cursor: ns-resize;
|
||||
}
|
||||
&.o_center_panel { // Needed because of chrome
|
||||
@include o-position-absolute($top: 0, $bottom: 0, $left: 43%, $right: 43%);
|
||||
.o-field-placeholder {
|
||||
border: 1px dashed $o-brand-primary;
|
||||
}
|
||||
&.o_right_panel { // Needed because of chrome
|
||||
@include o-position-absolute($top: 0, $bottom: 0, $left: 57%, $right: 0);
|
||||
}
|
||||
|
||||
> h4, > div {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
> button {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.o_left_field_panel, .o_fields_list {
|
||||
border: 1px solid $o-brand-lightsecondary;
|
||||
overflow: auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.o_export_tree_item {
|
||||
.o_remove_field {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
user-select: none;
|
||||
|
||||
&.o_selected > .o_tree_column {
|
||||
background-color: $o-brand-odoo;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.o_expand_parent {
|
||||
@include o-position-absolute($top: 4px, $left: 5px);
|
||||
font-size: 10px;
|
||||
}
|
||||
.o_tree_column {
|
||||
display: block;
|
||||
|
||||
&.o_required {
|
||||
border-bottom: 2px solid $o-main-text-color;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.o_save_list, .o_exported_lists {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
> label, > button {
|
||||
margin: 0 0 5px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
> input, > select {
|
||||
width: 0;
|
||||
flex: 1 0 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1279,79 +1279,76 @@
|
||||
</div>
|
||||
|
||||
<div t-name="ExportDialog" class="o_export">
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<strong>What do you want to do?</strong>
|
||||
<div class="o_import_compat">
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="o_import_compat_name" value="" checked="checked"/>
|
||||
Use data in a spreadsheet (export all data)
|
||||
</label>
|
||||
</div>
|
||||
<div class="radio">
|
||||
<label>
|
||||
<input type="radio" name="o_import_compat_name" value="yes"/>
|
||||
Update data (import-compatible export)
|
||||
</label>
|
||||
</div>
|
||||
<div class="row o-export-panel no-gutters">
|
||||
<div class="col-6 d-flex o_left_panel">
|
||||
<div class="form-check o_import_compat">
|
||||
<input class="form-check-input" id="o-update-data" type="checkbox"/>
|
||||
<label class="form-check-label" for="o-update-data">
|
||||
I want to update data (import-compatible export)
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<h4>Available fields</h4>
|
||||
</div>
|
||||
<div class="input-group mb-3">
|
||||
<input type="search" class="form-control o_export_search_input" id="o-export-search-filter" placeholder="Search" />
|
||||
</div>
|
||||
<div class="border o_left_field_panel"/>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<strong>Export Format:</strong>
|
||||
<div class="o_export_format"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="o_export_panel">
|
||||
<div class="o_left_panel">
|
||||
<h4>Available fields</h4>
|
||||
<div class="o_left_field_panel"/>
|
||||
</div>
|
||||
<div class="o_center_panel">
|
||||
<button type="button" class="btn btn-secondary o_add_field">Add</button>
|
||||
<button type="button" class="btn btn-secondary o_remove_field">Remove</button>
|
||||
<button type="button" class="btn btn-secondary o_remove_all_field">Remove All</button>
|
||||
<button type="button" class="btn btn-secondary o_move_up">Move Up</button>
|
||||
<button type="button" class="btn btn-secondary o_move_down">Move Down</button>
|
||||
</div>
|
||||
<div class="o_right_panel">
|
||||
<h4>
|
||||
<a href="#" class="float-right o_toggle_save_list">Save fields list</a>
|
||||
Fields to export
|
||||
</h4>
|
||||
<div class="o_save_list"/>
|
||||
<div class="col-6 d-flex o_right_panel">
|
||||
<div class="o_export_format">
|
||||
<strong>Export Format:</strong>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<h4>Fields to export</h4>
|
||||
</div>
|
||||
<div class="o_save_list">
|
||||
<t t-call="Export.SaveList"/>
|
||||
</div>
|
||||
<div class="o_exported_lists"/>
|
||||
<select class="o_fields_list" multiple="multiple"></select>
|
||||
<div class="border o_right_field_panel">
|
||||
<ul class="o_fields_list list-unstyled"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<t t-name="Export.TreeItems">
|
||||
<t t-foreach="fields" t-as="field">
|
||||
<t t-set="has_child" t-value="field.children && (field.id).split('/').length != 3" />
|
||||
<div t-att-data-id="field.id" t-attf-class="o_export_tree_item #{has_child ? 'haschild' : ''}" tabindex="-1" role="treeitem"> <!-- tabindex make the div focusable -->
|
||||
<t t-if="has_child">
|
||||
<span class="o_expand o_expand_parent fa fa-chevron-right" role="img" aria-label="Expand parents" title="Expand parents"/>
|
||||
<span class="o_tree_column o_expand" t-att-title="debug and field.id or None"><t t-esc="field.string"/></span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="o_tree_column" t-att-title="debug and field.id or None"><t t-esc="field.string"/></span>
|
||||
</t>
|
||||
<div t-att-data-id="field.id" t-attf-class="o_export_tree_item #{has_child ? 'haschild o_expand' : ''}" tabindex="-1" role="treeitem"> <!-- tabindex make the div focusable -->
|
||||
<span t-if="has_child" class="o_expand_parent fa fa-chevron-right" role="img" aria-label="Show sub-fields" title="Show sub-fields"/>
|
||||
<div t-attf-class="o_tree_column" t-att-title="debug and field.id or None">
|
||||
<t t-esc="field.string"/>
|
||||
<span title="Select field" class="fa fa-plus pull-right m-1 o_add_field"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-name="Export.SaveList">
|
||||
<label>Save as:</label> <input type="text"/><button type="button" class="btn btn-secondary">Ok</button>
|
||||
<div class="input-group mb-3">
|
||||
<label class="pt-2 font-weight-bold">Save as : </label>
|
||||
<input class="form-control ml-4 o_save_list_name"
|
||||
placeholder="New template"/>
|
||||
<button type="button" class="btn btn-secondary ml-1 o_save_list_btn"><i class="fa fa-floppy-o"/></button>
|
||||
<button type="button" class="btn btn-secondary ml-1 o_cancel_list_btn"><i class="fa fa-times"/></button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="Export.SavedList">
|
||||
<label>Saved exports: </label>
|
||||
<select class="o_exported_lists_select">
|
||||
<option></option>
|
||||
<t t-foreach="existing_exports" t-as="export">
|
||||
<option t-att-value="export.id"><t t-esc="export.name"/></option>
|
||||
</t>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary o_delete_exported_list">Delete</button>
|
||||
<div class="input-group mb-3">
|
||||
<label class="pt-2 mb-0 font-weight-bold">Template: </label>
|
||||
<select class="form-control ml-4 o_exported_lists_select">
|
||||
<option></option>
|
||||
<option class="font-italic" value="new_template">New template</option>
|
||||
<t t-foreach="existing_exports" t-as="export">
|
||||
<option t-att-value="export.id">
|
||||
<t t-esc="export.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary d-none ml-1 o_delete_exported_list">
|
||||
<i class="fa fa-trash"/>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="Throbber">
|
||||
|
||||
@@ -36,7 +36,7 @@ QUnit.module('widgets', {
|
||||
|
||||
|
||||
QUnit.test('exporting all data in list view', async function (assert) {
|
||||
assert.expect(6);
|
||||
assert.expect(8);
|
||||
|
||||
var blockUI = framework.blockUI;
|
||||
var unblockUI = framework.unblockUI;
|
||||
@@ -75,12 +75,12 @@ QUnit.module('widgets', {
|
||||
children: true,
|
||||
}, {
|
||||
children: false,
|
||||
field_type: 'text',
|
||||
id: "note",
|
||||
field_type: 'char',
|
||||
id: "foo",
|
||||
relation_field: null,
|
||||
required: false,
|
||||
string: 'Description',
|
||||
value: "note",
|
||||
string: 'Foo',
|
||||
value: "foo",
|
||||
}
|
||||
]);
|
||||
}
|
||||
@@ -99,15 +99,15 @@ QUnit.module('widgets', {
|
||||
await testUtils.dom.click(list.sidebar.$('a:contains(Export)'));
|
||||
|
||||
assert.strictEqual($('.modal').length, 1, "a modal dialog should be open");
|
||||
assert.strictEqual($('span.o_tree_column:contains(Activities)').length, 1,
|
||||
assert.strictEqual($('div.o_tree_column:contains(Activities)').length, 1,
|
||||
"the Activities field should be in the list of exportable fields");
|
||||
assert.strictEqual($('.modal .o_export_field').length, 1, "There should be only one export field");
|
||||
assert.strictEqual($('.modal .o_export_field').data('field_id'), 'foo', "There should be only one export field");
|
||||
|
||||
// select the field Description, click on add, then export and close
|
||||
await testUtils.dom.click($('.modal span:contains(Description)'));
|
||||
await testUtils.dom.click($('.modal .o_add_field'));
|
||||
await testUtils.dom.click($('.modal span:contains(Export To File)'));
|
||||
await testUtils.dom.click($('.modal .o_tree_column:contains(Foo) .o_add_field'));
|
||||
await testUtils.dom.click($('.modal span:contains(Export)'));
|
||||
await testUtils.dom.click($('.modal span:contains(Close)'));
|
||||
|
||||
list.destroy();
|
||||
framework.blockUI = blockUI;
|
||||
framework.unblockUI = unblockUI;
|
||||
@@ -119,7 +119,7 @@ QUnit.module('widgets', {
|
||||
});
|
||||
|
||||
QUnit.test('saving fields list when exporting data', async function (assert) {
|
||||
assert.expect(5);
|
||||
assert.expect(4);
|
||||
|
||||
var create = data.DataSet.prototype.create;
|
||||
|
||||
@@ -154,7 +154,15 @@ QUnit.module('widgets', {
|
||||
params: {"model": "mail.activity", "prefix": "activity_ids", "name": "Activities"},
|
||||
relation_field: "res_id",
|
||||
children: true,
|
||||
},
|
||||
}, {
|
||||
children: false,
|
||||
field_type: 'char',
|
||||
id: "foo",
|
||||
relation_field: null,
|
||||
required: false,
|
||||
string: 'Foo',
|
||||
value: "foo",
|
||||
}
|
||||
]);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
@@ -169,17 +177,14 @@ QUnit.module('widgets', {
|
||||
"a modal dialog should be open");
|
||||
|
||||
// Select 'Activities' in fields to export
|
||||
assert.strictEqual($('.modal select.o_fields_list option').length, 0,
|
||||
"the fields list should be empty");
|
||||
await testUtils.dom.click($('.modal .o_export_tree_item:contains(Activities)'));
|
||||
await testUtils.dom.click($('.modal button:contains(Add)'));
|
||||
assert.strictEqual($('.modal select.o_fields_list option').length, 1,
|
||||
"there should be one item in the fields list");
|
||||
await testUtils.dom.click($('.modal .o_export_tree_item:contains(Activities) .o_add_field'));
|
||||
assert.strictEqual($('.modal .o_fields_list .o_export_field').length, 2,
|
||||
"there should be two items in the fields list");
|
||||
// Save as template
|
||||
await testUtils.fields.editAndTrigger($('.modal .o_exported_lists_select'), 'new_template', ['change']);
|
||||
await testUtils.fields.editInput($('.modal .o_save_list .o_save_list_name'), 'fields list');
|
||||
await testUtils.dom.click($('.modal .o_save_list .o_save_list_btn'));
|
||||
|
||||
// Save fields list
|
||||
await testUtils.dom.click($('.modal a:contains(Save fields list)'));
|
||||
await testUtils.fields.editInput($('.modal .o_save_list > input'), 'fields list');
|
||||
await testUtils.dom.click($('.modal .o_save_list > button'));
|
||||
assert.verifySteps(['create'],
|
||||
"create should have been called");
|
||||
|
||||
@@ -191,6 +196,67 @@ QUnit.module('widgets', {
|
||||
data.DataSet.prototype.create = create;
|
||||
});
|
||||
|
||||
QUnit.test('Export dialog UI test', async function (assert) {
|
||||
assert.expect(4);
|
||||
var list = await createView({
|
||||
View: ListView,
|
||||
model: 'partner',
|
||||
data: this.data,
|
||||
arch: '<tree><field name="foo"/></tree>',
|
||||
viewOptions: {
|
||||
hasSidebar: true,
|
||||
},
|
||||
mockRPC: function (route) {
|
||||
if (route === '/web/export/formats') {
|
||||
return Promise.resolve([
|
||||
{tag: 'csv', label: 'CSV' },
|
||||
{tag: 'xls', label: 'Excel' },
|
||||
]);
|
||||
}
|
||||
if (route === '/web/export/get_fields') {
|
||||
return Promise.resolve([
|
||||
{
|
||||
field_type: "one2many",
|
||||
string: "Activities",
|
||||
required: false,
|
||||
value: "activity_ids/id",
|
||||
id: "activity_ids",
|
||||
params: {"model": "mail.activity", "prefix": "activity_ids", "name": "Activities" },
|
||||
relation_field: "res_id",
|
||||
children: true,
|
||||
}, {
|
||||
children: false,
|
||||
field_type: 'char',
|
||||
id: "foo",
|
||||
relation_field: null,
|
||||
required: false,
|
||||
string: 'Foo',
|
||||
value: "foo",
|
||||
}
|
||||
]);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
// Open the export modal
|
||||
await testUtils.dom.click(list.$('.o_data_row .o_list_record_selector input'));
|
||||
await testUtils.dom.click(list.sidebar.$('.o_dropdown_toggler_btn:contains(Action)'));
|
||||
await testUtils.dom.click(list.sidebar.$('a:contains(Export)'));
|
||||
|
||||
assert.strictEqual($('.modal .o_export_tree_item:visible').length, 2, "There should be only two items visible");
|
||||
await testUtils.dom.click($('.modal .o_export_search_input'));
|
||||
$('.modal .o_export_search_input').val('Activities').trigger($.Event('input', {
|
||||
keyCode: 65,
|
||||
}));
|
||||
assert.strictEqual($('.modal .o_export_tree_item:visible').length, 1, "Only match item visible");
|
||||
// Add field
|
||||
await testUtils.dom.click($('.modal div:contains(Activities) .o_add_field'));
|
||||
assert.strictEqual($('.modal .o_fields_list li').length, 2, "There should be two fields in export field list.");
|
||||
// Remove field
|
||||
await testUtils.dom.click($('.modal .o_fields_list li:first .o_remove_field'));
|
||||
assert.strictEqual($('.modal .o_fields_list li').length, 1, "There should be only one field in list");
|
||||
list.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user