[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:
Kishan Gajjar
2019-08-12 13:38:31 +00:00
committed by Xavier Morel
parent b6193a6b8d
commit 22b598bc1b
5 changed files with 366 additions and 313 deletions
+36 -39
View File
@@ -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')
+144 -113
View File
@@ -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;
+43 -81
View File
@@ -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;
}
}
}
}
+55 -58
View File
@@ -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 &amp;&amp; (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();
});
});
});