[IMP] web: add support of "sample" attribute in views

With this commit, it is now possible to display sample (fake)
data in empty views, in the hope of easing user onboarding.
This can be enabled (in list and kanban views) with attribute
sample="1" on the arch root element. In this case, if there is
no data to display, sample data will be generated based on
heuristics (depending on field types and names). This can be
used in addition to the no content helper.

Task 2232801

X-original-commit: 7c8e627ff5029cb539d702705e5ce53d658c76ed
Co-authored-by: Aaron Bohy <aab@odoo.com>
Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com>
Co-authored-by: Jérémy Hennecart <jeh@odoo.com>
Co-authored-by: Julien Mougenot <jum@odoo.com>
This commit is contained in:
Jérémy Hennecart
2020-06-22 13:04:01 +00:00
committed by Aaron Bohy
co-authored by Aaron Bohy Mathieu Duckerts-Antoine Julien Mougenot
parent 96fcefb6f1
commit c7964657ec
32 changed files with 2592 additions and 169 deletions
+12 -5
View File
@@ -109,9 +109,11 @@ var Controller = Widget.extend({
* @param {Model} model
* @param {Renderer} renderer
* @param {Object} params
* @param {any} [params.handle=null]
*/
init: function (parent, model, renderer, params) {
this._super.apply(this, arguments);
this.handle = params.handle || null;
this.model = model;
this.renderer = renderer;
},
@@ -178,11 +180,13 @@ var Factory = Class.extend({
var self = this;
var model = this.getModel(parent);
return Promise.all([this._loadData(model), ajax.loadLibs(this)]).then(function (result) {
var state = result[0];
const { state, handle } = result[0];
var renderer = self.getRenderer(parent, state);
var Controller = self.Controller || self.config.Controller;
const initialState = model.get(handle);
var controllerParams = _.extend({
initialState: state,
initialState,
handle,
}, self.controllerParams);
var controller = new Controller(parent, model, renderer, controllerParams);
model.setParent(controller);
@@ -221,13 +225,16 @@ var Factory = Class.extend({
*
* @private
* @param {Model} model a Model instance
* @param {Object} [options={}]
* @param {boolean} [options.withSampleData=true]
* @returns {Promise<*>} a promise that resolves to the value returned by
* the get method from the model
* @todo: get rid of loadParams (use modelParams instead)
*/
_loadData: function (model) {
return model.load(this.loadParams).then(function () {
return model.get.apply(model, arguments);
_loadData: function (model, options = {}) {
options.withSampleData = 'withSampleData' in options ? options.withSampleData : true;
return model.load(this.loadParams).then(function (handle) {
return { state: model.get(handle, options), handle };
});
},
});
@@ -259,23 +259,23 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
* updating the renderer and wait for the rendering to complete.
*
* @param {Object} params will be given to the model and to the renderer
* @param {Object} [options]
* @param {Object} [options={}]
* @param {boolean} [options.reload=true] if true, the model will reload data
*
* @returns {Promise}
*/
update: async function (params, options = {}) {
async update(params, options = {}) {
const shouldReload = 'reload' in options ? options.reload : true;
if (shouldReload) {
this.handle = await this.dp.add(this.model.reload(this.handle, params));
}
const localState = this.renderer.getLocalState();
const state = this.model.get(this.handle);
const state = this.model.get(this.handle, { withSampleData: true });
const promises = [
this.updateRendererState(state, params).then(() => {
this.renderer.setLocalState(localState);
}),
this._update(state, params),
this._update(this.model.get(this.handle), params)
];
await this.dp.add(Promise.all(promises));
this.updateButtons();
@@ -339,6 +339,20 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
state: this.getState(),
});
},
/**
* @private
* @param {function} callback function to execute before removing classname
* 'o_view_sample_data' (may be async). This allows to reload and/or
* rerender before removing the className, thus preventing the view from
* flickering.
*/
async _removeSampleData(callback) {
this.model.leaveSampleMode();
if (callback) {
await callback();
}
this.el.classList.remove('o_view_sample_data');
},
/**
* Renders the html provided by the route specified by the
* bannerRoute attribute on the controller (banner_route in the template).
@@ -409,7 +423,7 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
* @param {Object} [params.shouldUpdateControlPanel]
* @returns {Promise}
*/
_update: function (state, params) {
async _update(state, params) {
// AAB: update the control panel -> this will be moved elsewhere at some point
if (!this.$buttons) {
this.renderButtons();
@@ -423,7 +437,8 @@ var AbstractController = mvc.Controller.extend(ActionMixin, {
promises.push(this.updateControlPanel());
}
this._pushState();
return Promise.all(promises);
await Promise.all(promises);
this.el.classList.toggle('o_view_sample_data', this.model.isInSampleMode());
},
/**
* Can be used to update the key 'cp_content'. This method is called in start and _update methods.
@@ -12,25 +12,199 @@ odoo.define('web.AbstractModel', function (require) {
* Note that the model is not a widget, it does not need to be rendered or
* appended to the dom. However, it inherits from the EventDispatcherMixin,
* in order to be able to notify its parent by bubbling events up.
*
* The model is able to generate sample (fake) data when there is no actual data
* in database. This feature can be activated by instantiating the model with
* param "useSampleModel" set to true. In this case, the model instantiates a
* duplicated version of itself, parametrized to call a SampleServer (JS)
* instead of doing RPCs. Here is how it works: the main model first load the
* data normally (from database), and then checks whether the result is empty or
* not. If it is, it asks the sample model to load with the exact same params,
* and it thus enters in "sample" mode. The model keeps doing this at reload,
* but only if the (re)load params haven't changed: as soon as a param changes,
* the "sample" mode is left, and it never enters it again in the future (in the
* lifetime of the model instance). To access those sample data from the outside,
* 'get' must be called with the the option "withSampleData" set to true. In
* this case, if the main model is in "sample" mode, it redirects the call to the
* sample model.
*/
var fieldUtils = require('web.field_utils');
var mvc = require('web.mvc');
const SampleServer = require('web.SampleServer');
var AbstractModel = mvc.Model.extend({
/**
* @param {Widget} parent
* @param {Object} [params={}]
* @param {Object} [params.fields]
* @param {string} [params.modelName]
* @param {boolean} [params.isSampleModel=false] if true, will fetch data
* from a SampleServer instead of doing RPCs
* @param {boolean} [params.useSampleModel=false] if true, will use a sample
* model to generate sample data when there is no "real" data in database
* @param {AbstractModel} [params.SampleModel] the AbstractModel class
* to instantiate as sample model. This model won't do any rpc, but will
* rather call a SampleServer that will generate sample data. This param
* must be set when params.useSampleModel is true.
*/
init(parent, params = {}) {
this._super(...arguments);
this.useSampleModel = params.useSampleModel || false;
if (params.isSampleModel) {
this.isSampleModel = true;
this.sampleServer = new SampleServer(params.modelName, params.fields);
} else if (this.useSampleModel) {
const sampleModelParams = Object.assign({}, params, {
isSampleModel: true,
SampleModel: null,
useSampleModel: false,
});
this.sampleModel = new params.SampleModel(this, sampleModelParams);
this._isInSampleMode = false;
}
},
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
/**
* Override to call get on the sampleModel when we are in sample mode, and
* option 'withSampleData' is set to true.
*
* @override
* @param {any} _
* @param {Object} [options]
* @param {boolean} [options.withSampleData=false]
*/
get(_, options) {
let state;
if (options && options.withSampleData && this._isInSampleMode) {
state = this.sampleModel.__get(...arguments);
} else {
state = this.__get(...arguments);
}
return state;
},
/**
* Under some conditions, the model is designed to generate sample data if
* there is no real data in database. This function returns a boolean which
* indicates the mode of the model: if true, we are in "sample" mode.
*
* @returns {boolean}
*/
isInSampleMode() {
return !!this._isInSampleMode;
},
/**
* Disables the sample data (forever) on this model instance.
*/
leaveSampleMode() {
if (this.useSampleModel) {
this.useSampleModel = false;
this._isInSampleMode = false;
this.sampleModel.destroy();
}
},
/**
* Override to check if we need to call the sample model (and if so, to do
* it) after loading the data, in the case where there is no real data to
* display.
*
* @override
*/
async load(params) {
this.loadParams = params;
const handle = await this.__load(...arguments);
await this._callSampleModel('__load', handle, ...arguments);
return handle;
},
/**
* When something changes, the data may need to be refetched. This is the
* job for this method: reloading (only if necessary) all the data and
* making sure that they are ready to be redisplayed.
* Sometimes, we reload the data with the "same" params as the initial load
* params (see '_haveParamsChanged'). When we do, if we were in "sample" mode,
* we call again the sample server after the reload if there is still no data
* to display. When the parameters change, we automatically leave "sample"
* mode.
*
* @param {Object} params
* @param {any} _
* @param {Object} [params]
* @returns {Promise}
*/
reload: function (params) {
async reload(_, params) {
const handle = await this.__reload(...arguments);
if (this._isInSampleMode) {
if (!this._haveParamsChanged(params)) {
await this._callSampleModel('__reload', handle, ...arguments);
} else {
this.leaveSampleMode();
}
}
return handle;
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* @private
* @param {string} method
* @param {any} handle
* @param {...any} args
* @returns {Promise}
*/
async _callSampleModel(method, handle, ...args) {
if (this.useSampleModel && this._isEmpty(handle)) {
try {
if (method === '__load') {
await this.sampleModel.__load(...args);
} else if (method === '__reload') {
await this.sampleModel.__reload(...args);
}
this._isInSampleMode = true;
} catch (error) {
if (error instanceof SampleServer.UnimplementedRouteError) {
this.leaveSampleMode();
} else {
throw error;
}
}
} else {
this.leaveSampleMode();
}
},
/**
* @private
* @returns {Object}
*/
__get() {
return {};
},
/**
* This function can be overriden to determine if the result of a load or
* a reload is empty. In the affirmative, we will try to generate sample
* data to prevent from having an empty state to display.
*
* @private
* @params {any} handle, the value returned by a load or a reload
* @returns {boolean}
*/
_isEmpty(/* handle */) {
return false;
},
/**
* To override to do the initial load of the data (this function is supposed
* to be called only once).
*
* @private
* @returns {Promise}
*/
async __load() {
return Promise.resolve();
},
/**
@@ -57,6 +231,54 @@ var AbstractModel = mvc.Model.extend({
}
return value;
},
/**
* To override to reload data (this function may be called several times,
* after the initial load has been done).
*
* @private
* @returns {Promise}
*/
async __reload() {
return Promise.resolve();
},
/**
* Determines whether or not the given params (reload params) differ from
* the initial ones (this.loadParams). This is used to leave "sample" mode
* as soon as a parameter (e.g. domain) changes.
*
* @private
* @param {Object} [params={}]
* @param {Object} [params.context]
* @param {Array[]} [params.domain]
* @param {Object} [params.timeRanges]
* @param {string[]} [params.groupBy]
* @returns {boolean}
*/
_haveParamsChanged(params = {}) {
for (const key of ['context', 'domain', 'timeRanges']) {
if (key in params) {
const diff = JSON.stringify(params[key]) !== JSON.stringify(this.loadParams[key]);
if (diff) {
return true;
}
}
}
if (this.useSampleModel && 'groupBy' in params) {
return JSON.stringify(params.groupBy) !== JSON.stringify(this.loadParams.groupedBy);
}
},
/**
* Override to redirect all rpcs to the SampleServer if this.isSampleModel
* is true.
*
* @override
*/
async _rpc() {
if (this.isSampleModel) {
return this.sampleServer.mockRpc(...arguments);
}
return this._super(...arguments);
},
});
return AbstractModel;
@@ -162,6 +162,16 @@ var AbstractView = Factory.extend({
res_id: currentId,
res_ids: controllerState.resIds || params.ids || (currentId ? [currentId] : undefined),
};
this.modelParams = {
fields: this.fields,
modelName: params.modelName,
useSampleModel: !!(this.arch.attrs.sample && JSON.parse(this.arch.attrs.sample))
};
if (this.modelParams.useSampleModel) {
this.modelParams.SampleModel = this.config.Model;
}
var defaultOrder = this.arch.attrs.default_order;
if (defaultOrder) {
this.loadParams.orderedBy = _.map(defaultOrder.split(','), function (order) {
@@ -43,7 +43,6 @@ var BasicController = AbstractController.extend(FieldManagerMixin, {
this.hasButtons = params.hasButtons;
FieldManagerMixin.init.call(this, this.model);
this.mode = params.mode || 'readonly';
this.handle = this.initialState.id;
// savingDef is used to ensure that we always wait for pending save
// operations to complete before checking if there are changes to
// discard when discardChanges is called
@@ -164,6 +164,13 @@ var BasicModel = AbstractModel.extend({
this.batchedRPCsRequests = [];
this.localData = Object.create(null);
// used to generate dataPoint ids. Note that the counter is set to 0 for
// each instance, and this is mandatory for the sample data feature to
// work: we need both the main model and the sample model to generate the
// same datapoint ids for their common data (groups, when there are real
// groups in database), so that we can easily do the mapping between
// real and sample data.
this.__id = 0;
this._super.apply(this, arguments);
},
@@ -549,7 +556,7 @@ var BasicModel = AbstractModel.extend({
this.localData[listID].orderedResIDs = list.res_ids;
},
/**
* The get method first argument is the handle returned by the load method.
* The __get method first argument is the handle returned by the load method.
* It is optional (the handle can be undefined). In some case, it makes
* sense to use the handle as a key, for example the BasicModel holds the
* data for various records, each with its local ID.
@@ -563,7 +570,7 @@ var BasicModel = AbstractModel.extend({
* @param {boolean} [options.raw=false] if true, will not follow relations
* @returns {Object}
*/
get: function (id, options) {
__get: function (id, options) {
var self = this;
options = options || {};
@@ -601,7 +608,7 @@ var BasicModel = AbstractModel.extend({
relDataPoint = this.localData[data[fieldName]];
data[fieldName] = relDataPoint ? relDataPoint.res_id : false;
} else {
data[fieldName] = this.get(data[fieldName]) || false;
data[fieldName] = this.__get(data[fieldName]) || false;
}
} else if (field.type === 'reference') {
if (options.raw) {
@@ -610,7 +617,7 @@ var BasicModel = AbstractModel.extend({
relDataPoint.model + ',' + relDataPoint.res_id :
false;
} else {
data[fieldName] = this.get(data[fieldName]) || false;
data[fieldName] = this.__get(data[fieldName]) || false;
}
} else if (field.type === 'one2many' || field.type === 'many2many') {
if (options.raw) {
@@ -625,7 +632,7 @@ var BasicModel = AbstractModel.extend({
data[fieldName] = data[fieldName] || [];
}
} else {
data[fieldName] = this.get(data[fieldName]) || [];
data[fieldName] = this.__get(data[fieldName]) || [];
}
}
}
@@ -687,7 +694,7 @@ var BasicModel = AbstractModel.extend({
context: _.extend({}, element.context),
count: element.count,
data: _.map(element.data, function (elemID) {
return self.get(elemID, options);
return self.__get(elemID, options);
}),
domain: element.domain.slice(0),
fields: element.fields,
@@ -701,6 +708,7 @@ var BasicModel = AbstractModel.extend({
id: element.id,
isDirty: element.isDirty,
isOpen: element.isOpen,
isSample: this.isSampleModel,
limit: element.limit,
model: element.model,
offset: element.offset,
@@ -750,6 +758,27 @@ var BasicModel = AbstractModel.extend({
});
return isDirty;
},
/**
* Returns true iff the datapoint is of type list and either:
* - is not grouped, and contains no records
* - is grouped, and contains columns, but all columns are empty
* In these cases, we will generate sample data to display, instead of an
* empty state.
*
* @override
*/
_isEmpty(dataPointID) {
const dataPoint = this.localData[dataPointID];
if (dataPoint.type === 'list') {
const hasRecords = dataPoint.count === 0;
if (dataPoint.groupedBy.length) {
return dataPoint.data.length > 0 && hasRecords;
} else {
return hasRecords;
}
}
return false;
},
/**
* Check if a localData is new, meaning if it is in the process of being
* created and no actual record exists in db. Note: if the localData is not
@@ -782,6 +811,7 @@ var BasicModel = AbstractModel.extend({
*
* @todo document all params
*
* @private
* @param {any} params
* @param {Object} [params.fieldsInfo={}] contains the fieldInfo of each field
* @param {Object} params.fields contains the description of each field
@@ -789,7 +819,8 @@ var BasicModel = AbstractModel.extend({
* @param {string} [params.recordID] an ID for an existing resource.
* @returns {Promise<string>} resolves to a local id, or handle
*/
load: function (params) {
__load: async function (params) {
await this._super(...arguments);
params.type = params.type || (params.res_id !== undefined ? 'record' : 'list');
// FIXME: the following seems only to be used by the basic_model_tests
// so it should probably be removed and the tests should be adapted
@@ -954,13 +985,15 @@ var BasicModel = AbstractModel.extend({
* Reload all data for a given resource. At any time there is at most one
* reload operation active.
*
* @private
* @param {string} id local id for a resource
* @param {Object} [options]
* @param {boolean} [options.keepChanges=false] if true, doesn't discard the
* changes on the record before reloading it
* @returns {Promise<string>} resolves to the id of the resource
*/
reload: function (id, options) {
__reload: async function (id, options) {
await this._super(...arguments);
return this.mutex.exec(this._reload.bind(this, id, options));
},
/**
@@ -2195,6 +2228,20 @@ var BasicModel = AbstractModel.extend({
}
return hasOnchange ? specs : false;
},
/**
* Ensures that dataPoint ids are always synchronized between the main and
* sample models when being in sample mode. Here, we now that __id in the
* sample model is always greater than __id in the main model (as it
* contains strictly more datapoints).
*
* @override
*/
async _callSampleModel() {
await this._super(...arguments);
if (this._isInSampleMode) {
this.__id = this.sampleModel.__id;
}
},
/**
* Compute the default value that the handle field should take.
* We need to compute this in order for new lines to be added at the correct position.
@@ -3968,7 +4015,7 @@ var BasicModel = AbstractModel.extend({
groupsCount: 0,
groupsLimit: type === 'list' && params.groupsLimit || null,
groupsOffset: 0,
id: _.uniqueId(params.modelName + '_'),
id: `${params.modelName}_${++this.__id}`,
isOpen: params.isOpen,
limit: type === 'record' ? 1 : (params.limit || Number.MAX_SAFE_INTEGER),
loadMoreOffset: 0,
@@ -4847,6 +4894,23 @@ var BasicModel = AbstractModel.extend({
return result.id;
});
},
/**
* Override to handle the case where we want sample data, and we are in a
* grouped kanban or list view with real groups, but all groups are empty.
* In this case, we use the result of the web_read_group rpc to tweak the
* data in the SampleServer instance of the sampleModel (so that calls to
* that server will return the same groups).
*
* @override
*/
async _rpc(params) {
const result = await this._super(...arguments);
if (this.sampleModel && params.method === 'web_read_group' && result.length) {
const sampleServer = this.sampleModel.sampleServer;
sampleServer.setExistingGroups(result.groups);
}
return result;
},
/**
* Allows to save a value in the specialData cache associated to a given
* record and fieldName. If the value in the cache was already the given
@@ -455,7 +455,7 @@ var BasicRenderer = AbstractRenderer.extend(WidgetAdapterMixin, {
* @returns {boolean}
*/
_hasContent: function () {
return this.state.count !== 0;
return this.state.count !== 0 && (!('isSample' in this.state) || !this.state.isSample);
},
/**
* Force the resequencing of the records after moving one of them to a given
@@ -197,7 +197,8 @@ var BasicView = AbstractView.extend({
}
}
return Promise.resolve(def).then(function () {
return model.get(record.id);
const handle = record.id;
return { state: model.get(handle), handle };
});
}
return this._super.apply(this, arguments);
@@ -173,7 +173,7 @@ return AbstractModel.extend({
* @override
* @returns {Object}
*/
get: function () {
__get: function () {
return _.extend({}, this.data, {
fields: this.fields
});
@@ -183,7 +183,7 @@ return AbstractModel.extend({
* @param {any} params
* @returns {Promise}
*/
load: function (params) {
__load: function (params) {
var self = this;
this.modelName = params.modelName;
this.fields = params.fields;
@@ -256,7 +256,7 @@ return AbstractModel.extend({
* @param {Array} [params.domain]
* @returns {Promise}
*/
reload: function (handle, params) {
__reload: function (handle, params) {
if (params.domain) {
this.data.domain = params.domain;
}
@@ -34,7 +34,7 @@ return AbstractModel.extend({
* @override
* @returns {Object}
*/
get: function () {
__get: function () {
return _.extend({}, this.chart);
},
/**
@@ -57,7 +57,7 @@ return AbstractModel.extend({
* @returns {Promise} The promise does not return a handle, we don't need
* to keep track of various entities.
*/
load: function (params) {
__load: function (params) {
var groupBys = params.context.graph_groupbys || params.groupBys;
this.initialGroupBys = groupBys;
this.fields = params.fields;
@@ -97,7 +97,7 @@ return AbstractModel.extend({
* @param {Object} [params.timeRanges]
* @returns {Promise}
*/
reload: function (handle, params) {
__reload: function (handle, params) {
if ('context' in params) {
this.chart.context = params.context;
this.chart.groupBy = params.context.graph_groupbys || this.chart.groupBy;
@@ -191,7 +191,7 @@ var KanbanColumn = Widget.extend({
*
* @returns {Promise}
*/
addQuickCreate: function () {
addQuickCreate: async function () {
if (this.folded) {
// first open the column, and then add the quick create
this.trigger_up('column_toggle_fold', {
@@ -204,7 +204,6 @@ var KanbanColumn = Widget.extend({
return Promise.reject();
}
this.trigger_up('close_quick_create'); // close other quick create widgets
this.trigger_up('start_quick_create');
var context = this.data.getContext();
context['default_' + this.groupedBy] = viewUtils.getGroupValue(this.data, this.groupedBy);
this.quickCreateWidget = new RecordQuickCreate(this, {
@@ -212,7 +211,10 @@ var KanbanColumn = Widget.extend({
formViewRef: this.quickCreateView,
model: this.modelName,
});
return this.quickCreateWidget.insertAfter(this.$header);
await this.quickCreateWidget.appendTo(document.createDocumentFragment());
this.trigger_up('start_quick_create');
this.quickCreateWidget.$el.insertAfter(this.$header);
this.quickCreateWidget.on_attach_callback();
},
/**
* Closes the quick create widget if it isn't dirty.
@@ -285,7 +287,7 @@ var KanbanColumn = Widget.extend({
* @private
*/
_onAddQuickCreate: function () {
this.addQuickCreate();
this.trigger_up('add_quick_create', { groupId: this.db_id });
},
/**
* @private
@@ -21,6 +21,7 @@ var qweb = core.qweb;
var KanbanController = BasicController.extend({
buttons_template: 'KanbanView.buttons',
custom_events: _.extend({}, BasicController.prototype.custom_events, {
add_quick_create: '_onAddQuickCreate',
quick_create_add_column: '_onAddColumn',
quick_create_record: '_onQuickCreateRecord',
resequence_columns: '_onResequenceColumn',
@@ -90,6 +91,20 @@ var KanbanController = BasicController.extend({
// Private
//--------------------------------------------------------------------------
/**
* Displays the record quick create widget in the requested column, given its
* id (in the first column by default). Ensures that we removed sample data
* if any, before displaying the quick create.
*
* @private
* @param {string} [groupId]
*/
_addQuickCreate(groupId) {
this._removeSampleData(async () => {
await this.update({ shouldUpdateControlPanel: false }, { reload: false });
return this.renderer.addQuickCreate(groupId);
});
},
/**
* @override method comes from field manager mixin
* @private
@@ -205,12 +220,13 @@ var KanbanController = BasicController.extend({
*/
_shouldBounceOnClick(element) {
const state = this.model.get(this.handle, {raw: true});
if (!state.count) {
if (!state.count || state.isSample) {
const classesList = [
'o_kanban_view',
'o_kanban_group',
'o_kanban_header',
'o_column_quick_create',
'o_view_nocontent_smiling_face'
'o_view_nocontent_smiling_face',
];
return classesList.some(c => element.classList.contains(c));
}
@@ -266,6 +282,15 @@ var KanbanController = BasicController.extend({
});
}).guardedCatch(this.reload.bind(this));
},
/**
* @private
* @param {OdooEvent} ev
* @returns {string} ev.data.groupId
*/
_onAddQuickCreate(ev) {
ev.stopPropagation();
this._addQuickCreate(ev.data.groupId);
},
/**
* @private
* @param {OdooEvent} ev
@@ -309,16 +334,13 @@ var KanbanController = BasicController.extend({
* @private
*/
_onButtonNew: function () {
var self = this;
var state = this.model.get(this.handle, {raw: true});
var quickCreateEnabled = this.quickCreateEnabled && viewUtils.isQuickCreateEnabled(state);
if (this.on_create === 'quick_create' && quickCreateEnabled && state.data.length) {
// activate the quick create in the first column when the mutex is
// unlocked, to ensure that there is no pending re-rendering that
// would remove it (e.g. if we are currently adding a new column)
this.mutex.getUnlockedDef().then(function () {
self.renderer.addQuickCreate();
});
this.mutex.getUnlockedDef().then(this._addQuickCreate.bind(this, null));
} else if (this.on_create && this.on_create !== 'quick_create') {
// Execute the given action
this.do_action(this.on_create, {
@@ -357,15 +379,12 @@ var KanbanController = BasicController.extend({
* @param {OdooEvent} ev
*/
_onDeleteColumn: function (ev) {
var self = this;
var column = ev.target;
var state = this.model.get(this.handle, {raw: true});
var relatedModelName = state.fields[state.groupedBy[0]].relation;
this.model
.deleteRecords([column.db_id], relatedModelName)
.then(function () {
self.update({}, {reload: !column.isEmpty()});
});
.then(this.update.bind(this, {}, {}));
},
/**
* @private
@@ -10,7 +10,6 @@ var BasicModel = require('web.BasicModel');
var viewUtils = require('web.viewUtils');
var KanbanModel = BasicModel.extend({
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
@@ -69,28 +68,36 @@ var KanbanModel = BasicModel.extend({
context: parent.context, // todo: combine with view context
})
.then(function (result) {
var newGroup = self._makeDataPoint({
modelName: parent.model,
context: parent.context,
domain: parent.domain.concat([[groupBy,"=",result[0]]]),
fields: parent.fields,
fieldsInfo: parent.fieldsInfo,
isOpen: true,
limit: parent.limit,
parentID: parent.id,
openGroupByDefault: true,
orderedBy: parent.orderedBy,
value: result,
viewType: parent.viewType,
});
if (parent.progressBar) {
newGroup.progressBarValues = _.extend({
counts: {},
}, parent.progressBar);
}
// newGroup.is_open = true;
const createGroupDataPoint = (model, parent) => {
const newGroup = model._makeDataPoint({
modelName: parent.model,
context: parent.context,
domain: parent.domain.concat([[groupBy, "=", result[0]]]),
fields: parent.fields,
fieldsInfo: parent.fieldsInfo,
isOpen: true,
limit: parent.limit,
parentID: parent.id,
openGroupByDefault: true,
orderedBy: parent.orderedBy,
value: result,
viewType: parent.viewType,
});
if (parent.progressBar) {
newGroup.progressBarValues = _.extend({
counts: {},
}, parent.progressBar);
}
return newGroup;
};
const newGroup = createGroupDataPoint(self, parent);
parent.data.push(newGroup.id);
if (self.isInSampleMode()) {
// in sample mode, create the new group in both models (main + sample)
const sampleParent = self.sampleModel.localData[parentID];
const newSampleGroup = createGroupDataPoint(self.sampleModel, sampleParent);
sampleParent.data.push(newSampleGroup.id);
}
return newGroup.id;
});
},
@@ -143,7 +150,7 @@ var KanbanModel = BasicModel.extend({
* @see _readTooltipFields
* @returns {Object}
*/
get: function () {
__get: function () {
var result = this._super.apply(this, arguments);
var dp = result && this.localData[result.id];
if (dp) {
@@ -178,7 +185,7 @@ var KanbanModel = BasicModel.extend({
/**
* @override
*/
load: function (params) {
__load: function (params) {
this.defaultGroupedBy = params.groupBy || [];
params.groupedBy = (params.groupedBy && params.groupedBy.length) ? params.groupedBy : this.defaultGroupedBy;
return this._super(params);
@@ -262,6 +269,12 @@ var KanbanModel = BasicModel.extend({
if (options && options.groupBy && !options.groupBy.length) {
options.groupBy = this.defaultGroupedBy;
}
return this._super(id, options);
},
/**
* @override
*/
__reload: function (id, options) {
var def = this._super(id, options);
if (options && options.loadMoreOffset) {
return def;
@@ -152,12 +152,20 @@ var KanbanRenderer = BasicRenderer.extend({
//--------------------------------------------------------------------------
/**
* Displays the quick create record in the first column.
* Displays the quick create record in the requested column (first one by
* default)
*
* @params {string} [groupId] local id of the group in which the quick create
* must be inserted
* @returns {Promise}
*/
addQuickCreate: function () {
return this.widgets[0].addQuickCreate();
addQuickCreate: function (groupId) {
let kanbanColumn;
if (groupId) {
kanbanColumn = this.widgets.find(column => column.db_id === groupId);
}
kanbanColumn = kanbanColumn || this.widgets[0];
return kanbanColumn.addQuickCreate();
},
/**
* Focuses the first kanban record
@@ -213,20 +213,22 @@ var ListController = BasicController.extend({
_addRecord: function (dataPointId) {
var self = this;
this._disableButtons();
return this.renderer.unselectRow().then(function () {
return self.model.addDefaultRecord(dataPointId, {
position: self.editable,
});
}).then(function (recordID) {
var state = self.model.get(self.handle);
self.renderer.updateState(state, {keepWidths: true})
.then(function () {
self.renderer.editRecord(recordID);
})
.then(() => {
self._updatePaging(state);
return this._removeSampleData(() => {
return this.renderer.unselectRow().then(function () {
return self.model.addDefaultRecord(dataPointId, {
position: self.editable,
});
}).then(this._enableButtons.bind(this)).guardedCatch(this._enableButtons.bind(this));
}).then(function (recordID) {
var state = self.model.get(self.handle);
self._updateRendererState(state, { keepWidths: true })
.then(function () {
self.renderer.editRecord(recordID);
})
.then(() => {
self._updatePaging(state);
});
}).then(this._enableButtons.bind(this)).guardedCatch(this._enableButtons.bind(this));
});
},
/**
* Assign on the buttons create additionnal behavior to facilitate the work of the users doing input only using the keyboard
@@ -524,7 +526,7 @@ var ListController = BasicController.extend({
*/
_shouldBounceOnClick() {
const state = this.model.get(this.handle, {raw: true});
return !state.count;
return !state.count || state.isSample;
},
/**
* Called when clicking on 'Archive' or 'Unarchive' in the sidebar.
@@ -25,7 +25,7 @@ odoo.define('web.ListModel', function (require) {
* @override
* @see _readGroupExtraFields
*/
get: function () {
__get: function () {
var result = this._super.apply(this, arguments);
var dp = result && this.localData[result.id];
if (dp && dp.groupData) {
@@ -35,6 +35,7 @@ var FIELD_CLASSES = {
};
var ListRenderer = BasicRenderer.extend({
className: 'o_list_view',
events: {
"mousedown": "_onMouseDown",
"click .o_optional_columns_dropdown .dropdown-item": "_onToggleOptionalColumn",
@@ -1021,80 +1022,82 @@ var ListRenderer = BasicRenderer.extend({
* this method does not wait for the field widgets to be ready.
*
* @override
* @private
* @returns {Promise} resolved when the view has been rendered
*/
_renderView: function () {
var self = this;
async _renderView() {
const oldPagers = this.pagers;
this.pagers = [];
let prom;
let tableWrapper;
if (this.state.count > 0 || !this.noContentHelp) {
// render a table if there are records, or if there is no no content
// helper (empty table in this case)
this.pagers = [];
// display the no content helper if there is no data to display
var displayNoContentHelper = !this._hasContent() && !!this.noContentHelp;
this.$el.toggleClass('o_list_view', !displayNoContentHelper);
if (displayNoContentHelper) {
// destroy the previously instantiated pagers, if any
oldPagers.forEach(pager => pager.destroy());
const orderedBy = this.state.orderedBy;
this.hasHandle = orderedBy.length === 0 || orderedBy[0].name === this.handleField;
this._computeAggregates();
this.$el.removeClass('table-responsive');
this.$el.html(this._renderNoContentHelper());
return this._super.apply(this, arguments);
}
const $table = $(
'<table class="o_list_table table table-sm table-hover table-striped"/>'
);
$table.toggleClass('o_list_table_grouped', this.isGrouped);
$table.toggleClass('o_list_table_ungrouped', !this.isGrouped);
const defs = [];
this.defs = defs;
if (this.isGrouped) {
$table.append(this._renderHeader());
$table.append(this._renderGroups(this.state.data));
$table.append(this._renderFooter());
var orderedBy = this.state.orderedBy;
this.hasHandle = orderedBy.length === 0 || orderedBy[0].name === this.handleField;
this._computeAggregates();
var $table = $('<table>').addClass('o_list_table table table-sm table-hover table-striped');
$table.toggleClass('o_list_table_grouped', this.isGrouped);
$table.toggleClass('o_list_table_ungrouped', !this.isGrouped);
var defs = [];
this.defs = defs;
if (this.isGrouped) {
$table.append(this._renderHeader());
$table.append(this._renderGroups(this.state.data));
$table.append(this._renderFooter());
} else {
$table.append(this._renderHeader());
$table.append(this._renderBody());
$table.append(this._renderFooter());
}
const tableWrapper = Object.assign(document.createElement('div'), {
className: 'table-responsive',
});
tableWrapper.appendChild($table[0]);
delete this.defs;
var prom = Promise.all(defs).then(() => {
// destroy the previously instantiated pagers, if any
oldPagers.forEach(pager => pager.destroy());
// Append the table to the main element
self.el.innerHTML = "";
dom.append(self.el, tableWrapper, {
callbacks: [{ widget: this }],
in_DOM: document.body.contains(self.el),
});
if (self.optionalColumns.length) {
self.$el.addClass('o_list_optional_columns');
self.$('table').append($('<i class="o_optional_columns_dropdown_toggle fa fa-ellipsis-v"/>'));
self.$el.append(self._renderOptionalColumnsDropdown());
} else {
$table.append(this._renderHeader());
$table.append(this._renderBody());
$table.append(this._renderFooter());
}
tableWrapper = Object.assign(document.createElement('div'), {
className: 'table-responsive',
});
tableWrapper.appendChild($table[0]);
delete this.defs;
prom = Promise.all(defs);
}
if (self.selection.length) {
var $checked_rows = self.$('tr').filter(function (index, el) {
return _.contains(self.selection, $(el).data('id'));
});
await Promise.all([this._super.apply(this, arguments), prom]);
this.el.innerHTML = "";
this.el.classList.remove('o_list_optional_columns');
// destroy the previously instantiated pagers, if any
oldPagers.forEach(pager => pager.destroy());
// append the table (if any) to the main element
if (tableWrapper) {
dom.append(this.el, tableWrapper, {
callbacks: [{ widget: this }],
in_DOM: document.body.contains(this.el),
});
if (this.optionalColumns.length) {
this.el.classList.add('o_list_optional_columns');
this.$('table').append(
$('<i class="o_optional_columns_dropdown_toggle fa fa-ellipsis-v"/>')
);
this.$el.append(this._renderOptionalColumnsDropdown());
}
if (this.selection.length) {
const $checked_rows = this.$('tr').filter(
(i, el) => this.selection.includes(el.dataset.id)
);
$checked_rows.find('.o_list_record_selector input').prop('checked', true);
if ($checked_rows.length === self.$('.o_data_row').length) { // all rows are checked
self.$('thead .o_list_record_selector input').prop('checked', true);
if ($checked_rows.length === this.$('.o_data_row').length) { // all rows are checked
this.$('thead .o_list_record_selector input').prop('checked', true);
}
}
});
return Promise.all([this._super.apply(this, arguments), prom]);
}
// display the no content helper if necessary
if (!this._hasContent() && !!this.noContentHelp) {
this.$el.append(this._renderNoContentHelper());
}
},
/**
* Each line or cell can be decorated according to a few simple rules. The
@@ -41,6 +41,7 @@ odoo.define('web.PivotController', function (require) {
init: function (parent, model, renderer, params) {
this._super(...arguments);
this.disableLinking = params.disableLinking;
this.measures = params.measures;
this.title = params.title;
// views to use in the action triggered when a data cell is clicked
@@ -273,7 +274,7 @@ odoo.define('web.PivotController', function (require) {
_onOpenView: function (ev) {
ev.stopPropagation();
const cell = ev.data;
if (cell.value === undefined || this.initialState.disableLinking) {
if (cell.value === undefined || this.disableLinking) {
return;
}
@@ -497,7 +497,7 @@ var PivotModel = AbstractModel.extend({
* @param {boolean} [options.raw=false]
* @returns {Object}
*/
get: function (options) {
__get: function (options) {
options = options || {};
var raw = options.raw || false;
var groupBys = this._getGroupBys();
@@ -546,7 +546,7 @@ var PivotModel = AbstractModel.extend({
* @param {Object} params.timeRanges
* @returns {Promise}
*/
load: function (params) {
__load: function (params) {
this.initialDomain = params.domain;
this.initialRowGroupBys = params.context.pivot_row_groupby || params.rowGroupBys;
this.defaultGroupedBy = params.groupedBy;
@@ -592,7 +592,7 @@ var PivotModel = AbstractModel.extend({
* @param {Object} [params.timeRanges]
* @returns {Promise}
*/
reload: function (handle, params) {
__reload: function (handle, params) {
var self = this;
var oldColGroupBys = this.data.colGroupBys;
var oldRowGroupBys = this.data.rowGroupBys;
@@ -114,9 +114,13 @@ odoo.define('web.PivotView', function (require) {
this.loadParams.default_order = params.default_order || this.arch.attrs.default_order;
this.loadParams.groupableFields = groupableFields;
this.rendererParams.widgets = widgets;
this.rendererParams.disableLinking = this.arch.attrs.disable_linking;
const disableLinking = !!(this.arch.attrs.disable_linking &&
JSON.stringify(this.arch.attrs.disable_linking));
this.rendererParams.widgets = widgets;
this.rendererParams.disableLinking = disableLinking;
this.controllerParams.disableLinking = disableLinking;
this.controllerParams.title = params.title || this.arch.attrs.string || _t("Untitled");
this.controllerParams.measures = measures;
@@ -51,13 +51,13 @@ var Model = AbstractModel.extend({
/**
* get
*/
get: function () {
__get: function () {
return this._state;
},
/**
* load
*/
load: function (params) {
__load: function (params) {
_.extend(this._state, _.pick(params, ['viewId', 'modelName', 'domain', 'context']));
return this._fetch();
@@ -65,7 +65,7 @@ var Model = AbstractModel.extend({
/**
* reload
*/
reload: function (_id, params) {
__reload: function (_id, params) {
_.extend(this._state, _.pick(params, ['domain', 'context']));
return this._fetch();
@@ -0,0 +1,609 @@
odoo.define('web.SampleServer', function (require) {
"use strict";
const session = require('web.session');
const utils = require('web.utils');
const Registry = require('web.Registry');
class UnimplementedRouteError extends Error {}
/**
* Helper function returning the value from a list of sample strings
* corresponding to the given ID.
* @param {number} id
* @param {string[]} sampleTexts
* @returns {string}
*/
function getSampleFromId(id, sampleTexts) {
return sampleTexts[(id - 1) % sampleTexts.length];
}
/**
* Helper function returning a regular expression specifically matching
* a given 'term' in a fieldName. For example `fieldNameRegex('abc')`:
* will match:
* - "abc"
* - "field_abc__def"
* will not match:
* - "aabc"
* - "abcd_ef"
* @param {...string} term
* @returns {RegExp}
*/
function fieldNameRegex(...terms) {
return new RegExp(`\\b((\\w+)?_)?(${terms.join('|')})(_(\\w+)?)?\\b`);
}
const DESCRIPTION_REGEX = fieldNameRegex('description', 'label', 'title', 'subject', 'message');
const EMAIL_REGEX = fieldNameRegex('email');
const PHONE_REGEX = fieldNameRegex('phone');
const URL_REGEX = fieldNameRegex('url');
/**
* Sample server class
*
* Represents a static instance of the server used when a RPC call sends
* empty values/groups while the attribute 'sample' is set to true on the
* view.
*
* This server will generate fake data and send them in the adequate format
* according to the route/method used in the RPC.
*/
class SampleServer {
/**
* @param {string} modelName
* @param {Object} fields
*/
constructor(modelName, fields) {
this.mainModel = modelName;
this.data = {};
this.data[modelName] = {
fields,
records: [],
};
// Generate relational fields' co models
for (const fieldName in fields) {
const field = fields[fieldName];
if (['many2one', 'one2many', 'many2many'].includes(field.type)) {
this.data[field.relation] = this.data[field.relation] || {
fields: {
display_name: { type: 'char' },
id: { type: 'integer' },
color: { type: 'integer' },
},
records: [],
};
}
}
// On some models, empty grouped Kanban or List view still contain
// real (empty) groups. In this case, we re-use the result of the
// web_read_group rpc to tweak sample data s.t. those real groups
// contain sample records.
this.existingGroups = null;
// Sample records generation is only done if necessary, so we delay
// it to the first "mockRPC" call. These flags allow us to know if
// the records have been generated or not.
this.populated = false;
this.existingGroupsPopulated = false;
}
//---------------------------------------------------------------------
// Public
//---------------------------------------------------------------------
/**
* This is the main entry point of the SampleServer. Mocks a request to
* the server with sample data.
* @param {Object} params
* @returns {any} the result obtained with the sample data
* @throws {Error} If called on a route/method we do not handle
*/
mockRpc(params) {
if (!(params.model in this.data)) {
throw new Error(`SampleServer: unknown model ${params.model}`);
}
this._populateModels();
switch (params.method || params.route) {
case '/web/dataset/search_read':
return this._mockSearchReadController(params);
case 'web_read_group':
return this._mockWebReadGroup(params);
case 'read_group':
return this._mockReadGroup(params);
case 'read_progress_bar':
return this._mockReadProgressBar(params);
case 'read':
return this._mockRead(params);
}
const mock = SampleServer.mockRegistry.get(params.model);
const mockFunction = mock && mock[params.method || params.route];
if (mockFunction) {
return mockFunction.call(this, params);
}
console.log(`SampleServer: unimplemented route "${params.method || params.route}"`);
throw new SampleServer.UnimplementedRouteError();
}
setExistingGroups(groups) {
this.existingGroups = groups;
}
//---------------------------------------------------------------------
// Private
//---------------------------------------------------------------------
/**
* @param {Object[]} measures, each measure has the form { fieldName, type }
* @param {Object[]} records
* @returns {Object}
*/
_aggregateFields(measures, records) {
const values = {};
for (const { fieldName, type } of measures) {
if (type === 'float' || type === 'integer') {
if (records.length) {
let value = 0;
for (const record of records) {
value += record[fieldName];
}
values[fieldName] = value;
} else {
values[fieldName] = null;
}
}
if (type === 'many2one') {
const ids = new Set(records.map(r => r[fieldName]));
values.fieldName = ids.size || null;
}
}
return values;
}
/**
* @param {any} value
* @param {Object} options
* @param {string} [options.interval]
* @param {string} [options.relation]
* @param {string} [options.type]
* @returns {any}
*/
_formatValue(value, options) {
if (!value) {
return false;
}
const { type, interval, relation } = options;
if (['date', 'datetime'].includes(type)) {
const fmt = SampleServer.FORMATS[interval];
return moment(value).format(fmt);
} else if (type === 'many2one') {
const rec = this.data[relation].records.find(({id}) => id === value);
return [value, rec.display_name];
} else {
return value;
}
}
/**
* Generates field values based on heuristics according to field types
* and names.
*
* @private
* @param {string} modelName
* @param {string} fieldName
* @param {number} id the record id
* @returns {any} the field value
*/
_generateFieldValue(modelName, fieldName, id) {
const field = this.data[modelName].fields[fieldName];
switch (field.type) {
case "boolean":
return fieldName === 'active' ? true : Math.random() < 0.5;
case "char":
case "text":
if (["display_name", "name"].includes(fieldName)) {
if (SampleServer.PEOPLE_MODELS.includes(modelName)) {
return getSampleFromId(id, SampleServer.SAMPLE_PEOPLE);
} else if (modelName === 'res.country') {
return getSampleFromId(id, SampleServer.SAMPLE_COUNTRIES);
}
}
if (fieldName === 'display_name') {
return getSampleFromId(id, SampleServer.SAMPLE_TEXTS);
} else if (["name", "reference"].includes(fieldName)) {
return `REF${String(id).padStart(4, '0')}`;
} else if (DESCRIPTION_REGEX.test(fieldName)) {
return getSampleFromId(id, SampleServer.SAMPLE_TEXTS);
} else if (EMAIL_REGEX.test(fieldName)) {
const emailName = getSampleFromId(id, SampleServer.SAMPLE_PEOPLE)
.replace(/ /, ".")
.toLowerCase();
return `${emailName}@sample.demo`;
} else if (PHONE_REGEX.test(fieldName)) {
return `+1 555 754 ${String(id).padStart(4, '0')}`;
} else if (URL_REGEX.test(fieldName)) {
return `http://sample${id}.com`;
}
return false;
case "date":
case "datetime": {
const delta = Math.floor((Math.random() - Math.random()) * SampleServer.DATE_DELTA);
const date = new moment().add(delta, "hour").format(
field.type === "date" ? "YYYY-MM-DD" : "YYYY-MM-DD HH:mm:ss"
);
return date;
}
case "float":
return Math.random() * SampleServer.MAX_FLOAT;
case "integer": {
let max = SampleServer.MAX_INTEGER;
if (fieldName.includes('color')) {
max = Math.random() < 0.5 ? SampleServer.MAX_COLOR_INT : 0;
}
return Math.floor(Math.random() * max);
}
case "monetary":
return Math.floor(Math.random() * SampleServer.MAX_MONETARY);
case "many2one":
if (field.relation === 'res.currency') {
return session.company_currency_id;
}
return this._getRandomSubRecordId();
case "one2many":
case "many2many": {
const ids = [this._getRandomSubRecordId(), this._getRandomSubRecordId()];
return [...new Set(ids)];
}
case "selection": {
if (field.selection.length > 0) {
const index = Math.floor(Math.random() * field.selection.length);
return field.selection[index][0];
}
return false;
}
default:
return false;
}
}
/**
* Generates a random id in the range of ids generated for sub models.
* @private
* @returns {number} id in [1, SUB_RECORDSET_SIZE]
*/
_getRandomSubRecordId() {
return Math.floor(Math.random() * SampleServer.SUB_RECORDSET_SIZE) + 1;
}
/**
* Mocks calls to the read method.
* @private
* @param {Object} params
* @param {string} params.model
* @param {Array[]} params.args (args[0] is the list of ids, args[1] is
* the list of fields)
* @returns {Object[]}
*/
_mockRead(params) {
const model = this.data[params.model];
const ids = params.args[0];
const fieldNames = params.args[1];
const records = [];
for (const r of model.records) {
if (!ids.includes(r.id)) {
continue;
}
const record = { id: r.id };
for (const fieldName of fieldNames) {
const field = model.fields[fieldName];
if (!field) {
record[fieldName] = false; // unknown field
} else if (field.type === 'many2one') {
const relModel = this.data[field.relation];
const relRecord = relModel.records.find(
relR => r[fieldName] === relR.id
);
record[fieldName] = relRecord ?
[relRecord.id, relRecord.display_name] :
false;
} else {
record[fieldName] = r[fieldName];
}
}
records.push(record);
}
return records;
}
/**
* Mocks calls to the read_group method.
*
* @param {Object} params
* @param {string} params.model
* @param {string[]} [params.fields] defaults to the list of all fields
* @param {string[]} params.groupBy
* @param {boolean} [params.lazy=true]
* @returns {Object[]} Object with keys groups and length
*/
_mockReadGroup(params) {
const lazy = 'lazy' in params ? params.lazy : true;
const model = params.model;
const fields = this.data[model].fields;
const records = this.data[model].records;
const normalizedGroupBys = [];
let groupBy = [];
if (params.groupBy.length) {
groupBy = lazy ? [params.groupBy[0]] : params.groupBy;
}
for (const groupBySpec of groupBy) {
let [fieldName, interval] = groupBySpec.split(':');
const { type, relation } = fields[fieldName];
if (type) {
const gb = { fieldName, type, interval, relation };
normalizedGroupBys.push(gb);
}
}
const groups = utils.groupBy(records, (record) => {
const vals = {};
for (const gb of normalizedGroupBys) {
const { fieldName, type } = gb;
let value;
if (['date', 'datetime'].includes(type)) {
value = this._formatValue(record[fieldName], gb);
} else {
value = record[fieldName];
}
vals[fieldName] = value;
}
return JSON.stringify(vals);
});
const measures = [];
for (const measureSpec of (params.fields || Object.keys(fields))) {
const [fieldName, aggregateFunction] = measureSpec.split(':');
const { type } = fields[fieldName];
if (!params.groupBy.includes(fieldName) && type &&
(type !== 'many2one' || aggregateFunction !== 'count_distinct')) {
measures.push({ fieldName, type });
}
}
const result = [];
for (const id in groups) {
const records = groups[id];
const group = { __domain: [] };
let countKey = `__count`;
if (normalizedGroupBys.length && lazy) {
countKey = `${normalizedGroupBys[0].fieldName}_count`;
}
group[countKey] = records.length;
const firstElem = records[0];
for (const gb of normalizedGroupBys) {
const { fieldName } = gb;
group[fieldName] = this._formatValue(firstElem[fieldName], gb);
}
Object.assign(group, this._aggregateFields(measures, records));
result.push(group);
}
return result;
}
/**
* Mocks calls to the read_progress_bar method.
* @private
* @param {Object} params
* @param {string} params.model
* @param {Object} params.kwargs
* @return {Object}
*/
_mockReadProgressBar(params) {
const groupBy = params.kwargs.group_by;
const progress_bar = params.kwargs.progress_bar;
const groupByField = this.data[params.model].fields[groupBy];
const data = {};
for (const record of this.data[params.model].records) {
let groupByValue = record[groupBy];
if (groupByField.type === "many2one") {
const relatedRecords = this.data[groupByField.relation].records;
const relatedRecord = relatedRecords.find(r => r.id === groupByValue);
groupByValue = relatedRecord.display_name;
}
if (!(groupByValue in data)) {
data[groupByValue] = {};
for (const key in progress_bar.colors) {
data[groupByValue][key] = 0;
}
}
const fieldValue = record[progress_bar.field];
if (fieldValue in data[groupByValue]) {
data[groupByValue][fieldValue]++;
}
}
return data;
}
/**
* Mocks calls to the /web/dataset/search_read route to return sample
* records.
* @private
* @param {Object} params
* @param {string} params.model
* @param {string[]} params.fields
* @returns {{ records: Object[], length: number }}
*/
_mockSearchReadController(params) {
const model = this.data[params.model];
const rawRecords = model.records.slice(0, SampleServer.SEARCH_READ_LIMIT);
const records = this._mockRead({
model: params.model,
args: [rawRecords.map(r => r.id), params.fields],
});
return { records, length: records.length };
}
/**
* Mocks calls to the web_read_group method to return groups populated
* with sample records. Only handles the case where the real call to
* web_read_group returned groups, but none of these groups contain
* records. In this case, we keep the real groups, and populate them
* with sample records.
* @private
* @param {Object} params
* @param {Object} [result] the result of a real call to web_read_group
* @returns {{ groups: Object[], length: number }}
*/
_mockWebReadGroup(params) {
let groups;
if (this.existingGroups) {
this._tweakExistingGroups(params);
groups = this.existingGroups;
} else {
groups = this._mockReadGroup(params);
}
return {
groups,
length: groups.length,
};
}
/**
* Updates the sample data such that the existing groups (in database)
* also exists in the sample, and such that there are sample records in
* those groups.
* @private
* @param {Object[]} groups empty groups returned by the server
* @param {Object} params
* @param {string} params.model
* @param {string[]} params.groupBy
*/
_populateExistingGroups(params) {
if (!this.existingGroupsPopulated) {
const groups = this.existingGroups;
this.groupsInfo = groups;
const groupBy = params.groupBy[0];
const values = groups.map(g => g[groupBy]);
const groupByField = this.data[params.model].fields[groupBy];
const groupedByM2O = groupByField.type === 'many2one';
if (groupedByM2O) { // re-populate co model with relevant records
this.data[groupByField.relation].records = values.map(v => {
return { id: v[0], display_name: v[1] };
});
}
for (const r of this.data[params.model].records) {
const value = getSampleFromId(r.id, values);
r[groupBy] = groupedByM2O ? value[0] : value;
}
this.existingGroupsPopulated = true;
}
}
/**
* Generates sample records for the models in this.data. Records will be
* generated once, and subsequent calls to this function will be skipped.
* @private
*/
_populateModels() {
if (!this.populated) {
for (const modelName in this.data) {
const model = this.data[modelName];
const fieldNames = Object.keys(model.fields).filter(f => f !== 'id');
const size = modelName === this.mainModel ?
SampleServer.MAIN_RECORDSET_SIZE :
SampleServer.SUB_RECORDSET_SIZE;
for (let id = 1; id <= size; id++) {
const record = { id };
for (const fieldName of fieldNames) {
record[fieldName] = this._generateFieldValue(modelName, fieldName, id);
}
model.records.push(record);
}
}
this.populated = true;
}
}
/**
* A real (web_)read_group call has been done, and it has returned groups,
* but they are all empty. This function updates the sample data such
* that those group values exist and those groups contain sample records.
* @private
* @param {Object[]} groups empty groups returned by the server
* @param {Object} params
* @param {string} params.model
* @param {string[]} params.fields
* @param {string[]} params.groupBy
* @returns {Object[]} groups with count and aggregate values updated
*
* TODO: rename
*/
_tweakExistingGroups(params) {
const groups = this.existingGroups;
this._populateExistingGroups(params);
// update count and aggregates for each group
const groupBy = params.groupBy[0].split(':')[0];
const groupByField = this.data[params.model].fields[groupBy];
const groupedByM2O = groupByField.type === 'many2one';
const records = this.data[params.model].records;
for (const g of groups) {
const groupValue = groupedByM2O ? g[groupBy][0] : g[groupBy];
const recordsInGroup = records.filter(r => r[groupBy] === groupValue);
g[`${groupBy}_count`] = recordsInGroup.length;
for (const field of params.fields) {
const fieldType = this.data[params.model].fields[field].type;
if (['integer, float', 'monetary'].includes(fieldType)) {
g[field] = recordsInGroup.reduce((acc, r) => acc + r[field], 0);
}
}
g.__data = {
records: this._mockRead({
model: params.model,
args: [recordsInGroup.map(r => r.id), params.fields],
}),
length: recordsInGroup.length,
};
}
}
}
SampleServer.FORMATS = {
day: 'YYYY-MM-DD',
week: 'ww YYYY',
month: 'MMMM YYYY',
quarter: '[Q]Q YYYY',
year: 'Y',
};
SampleServer.MAIN_RECORDSET_SIZE = 16;
SampleServer.SUB_RECORDSET_SIZE = 5;
SampleServer.SEARCH_READ_LIMIT = 10;
SampleServer.MAX_FLOAT = 100;
SampleServer.MAX_INTEGER = 50;
SampleServer.MAX_COLOR_INT = 7;
SampleServer.MAX_MONETARY = 100000;
SampleServer.DATE_DELTA = 24 * 7; // in hours -> 7 days
SampleServer.SAMPLE_COUNTRIES = ["Belgium", "France", "Portugal", "Singapore", "Australia"];
SampleServer.SAMPLE_PEOPLE = [
"John Miller", "Henry Campbell", "Carrie Helle", "Wendi Baltz", "Thomas Passot",
];
SampleServer.SAMPLE_TEXTS = [
"Laoreet id", "Volutpat blandit", "Integer vitae", "Viverra nam", "In massa",
];
SampleServer.PEOPLE_MODELS = [
'res.users', 'res.partner', 'hr.employee', 'mail.followers', 'mailing.contact'
];
SampleServer.UnimplementedRouteError = UnimplementedRouteError;
// mockRegistry allows to register mock version of methods or routes for
// specific models, for instance
// SampleServer.mockRegistry.registry('res.partner' {
// some_method: () => 23,
// some_route: () => "abcd",
// });
SampleServer.mockRegistry = new Registry();
return SampleServer;
});
@@ -1,5 +1,6 @@
.o_list_view {
position: relative;
height: 100%;
.o_list_table {
position: relative;
+36 -1
View File
@@ -40,7 +40,7 @@
// No content helper
.o_view_nocontent {
@include o-position-absolute(20%, 0, 0, 0);
@include o-position-absolute(30%, 0, 0, 0);
pointer-events: none;
z-index: 1;
@@ -72,3 +72,38 @@
}
}
}
// Sample data
@mixin web_view_sample_data {
opacity: 0.33;
pointer-events: none;
user-select: none;
}
.o_view_sample_data {
.o_list_view {
.o_data_row, .o_group_header, .o_list_table > tfoot {
@include web_view_sample_data;
}
.o_list_table {
cursor: default !important;
> thead .o_list_record_selector {
pointer-events: none;
}
}
}
.o_kanban_view {
.o_kanban_counter,
.o_kanban_record,
.o_kanban_toggle_fold,
.o_column_folded,
.o_column_archive_records,
.o_column_unarchive_records {
@include web_view_sample_data;
}
}
.o_nocontent_help {
border-radius: 100%;
background-color: #f9f9f9;
box-shadow: 0 0 120px 100px #f9f9f9;
}
}
@@ -282,7 +282,7 @@ odoo.define('web.test_utils_create', function (require) {
async function createModel(params) {
const widget = new Widget();
const model = new params.Model(widget);
const model = new params.Model(widget, params);
await testUtilsMock.addMockEnvironment(widget, params);
@@ -0,0 +1,130 @@
odoo.define('web.abstract_model_tests', function (require) {
"use strict";
const AbstractModel = require('web.AbstractModel');
const Domain = require('web.Domain');
QUnit.module('Views', {}, function () {
QUnit.module('AbstractModel');
QUnit.test('leave sample mode when unknown route is called on sample server', async function (assert) {
assert.expect(4);
const Model = AbstractModel.extend({
_isEmpty() {
return true;
},
async __load() {
if (this.isSampleModel) {
await this._rpc({ model: 'partner', method: 'unknown' });
}
},
});
const model = new Model(null, {
modelName: 'partner',
fields: {},
useSampleModel: true,
SampleModel: Model,
});
assert.ok(model.useSampleModel);
assert.notOk(model._isInSampleMode);
await model.load({});
assert.notOk(model.useSampleModel);
assert.notOk(model._isInSampleMode);
model.destroy();
});
QUnit.test("don't cath general error on sample server in sample mode", async function (assert) {
assert.expect(5);
const error = new Error();
const Model = AbstractModel.extend({
_isEmpty() {
return true;
},
async __reload() {
if (this.isSampleModel) {
await this._rpc({ model: 'partner', method: 'read_group' });
}
},
async _rpc() {
throw error;
},
});
const model = new Model(null, {
modelName: 'partner',
fields: {},
useSampleModel: true,
SampleModel: Model,
});
assert.ok(model.useSampleModel);
assert.notOk(model._isInSampleMode);
await model.load({});
assert.ok(model.useSampleModel);
assert.ok(model._isInSampleMode);
async function reloadModel() {
try {
await model.reload();
} catch (e) {
assert.strictEqual(e, error);
}
}
await reloadModel();
model.destroy();
});
QUnit.test('fetch sample data: concurrency', async function (assert) {
assert.expect(3);
const Model = AbstractModel.extend({
_isEmpty() {
return true;
},
__get() {
return { isSample: !!this.isSampleModel };
},
});
const model = new Model(null, {
modelName: 'partner',
fields: {},
useSampleModel: true,
SampleModel: Model,
});
await model.load({ domain: Domain.FALSE_DOMAIN, });
const beforeReload = model.get(null, { withSampleData: true });
const reloaded = model.reload(null, { domain: Domain.TRUE_DOMAIN });
const duringReload = model.get(null, { withSampleData: true });
await reloaded;
const afterReload = model.get(null, { withSampleData: true });
assert.strictEqual(beforeReload.isSample, true,
"Sample data flag must be true before reload"
);
assert.strictEqual(duringReload.isSample, true,
"Sample data flag must be true during reload"
);
assert.strictEqual(afterReload.isSample, false,
"Sample data flag must be true after reload"
);
});
});
});
@@ -1309,7 +1309,7 @@ odoo.define('web.basic_model_tests', function (require) {
res_id: 1,
};
model.load(params).then(function (resultID) {
await model.load(params).then(function (resultID) {
var record = model.get(resultID);
assert.ok(record.data.date instanceof moment,
"fetched date field should have been formatted");
@@ -1317,7 +1317,7 @@ odoo.define('web.basic_model_tests', function (require) {
params.res_id = 2;
model.load(params).then(function (resultID) {
await model.load(params).then(function (resultID) {
var record = model.get(resultID);
assert.strictEqual(record.data.date, false,
"unset date field should be false");
@@ -1340,7 +1340,7 @@ odoo.define('web.basic_model_tests', function (require) {
type: 'list',
};
model.load(params).then(function (resultID) {
await model.load(params).then(function (resultID) {
var record = model.get(resultID);
var firstRecord = record.data[0];
var secondRecord = record.data[1];
@@ -1367,7 +1367,7 @@ odoo.define('web.basic_model_tests', function (require) {
type: 'record',
};
model.load(params).then(function (resultID) {
await model.load(params).then(function (resultID) {
var record = model.get(resultID);
assert.strictEqual(record.data.date, false, "date default value should be false");
});
@@ -1391,7 +1391,7 @@ odoo.define('web.basic_model_tests', function (require) {
type: 'record',
};
model.load(params).then(function (resultID) {
await model.load(params).then(function (resultID) {
var record = model.get(resultID);
assert.ok(_.isEqual(record.data.category.res_ids, [12, 14]),
"category field should have correct default value");
@@ -1958,7 +1958,7 @@ odoo.define('web.basic_model_tests', function (require) {
},
});
model.load(this.params);
await model.load(this.params);
assert.verifySteps(['read'],
"there should be only one read");
model.destroy();
@@ -1996,7 +1996,7 @@ odoo.define('web.basic_model_tests', function (require) {
data: this.data,
});
model.load(params).then(function (resultID) {
await model.load(params).then(function (resultID) {
var record = model.get(resultID);
assert.strictEqual(record.data.product_ids.data[0].data.date, false,
"date value should be in data, and should be false");
+524 -1
View File
@@ -2,6 +2,7 @@ odoo.define('web.kanban_tests', function (require) {
"use strict";
var AbstractField = require('web.AbstractField');
const Domain = require('web.Domain');
var fieldRegistry = require('web.field_registry');
var KanbanColumnProgressBar = require('web.KanbanColumnProgressBar');
var kanbanExamplesRegistry = require('web.kanban_examples_registry');
@@ -4045,6 +4046,529 @@ QUnit.module('Views', {
kanban.destroy();
});
QUnit.test('empty grouped kanban with sample data and no columns', async function (assert) {
assert.expect(3);
this.data.partner.records = [];
const kanban = await createView({
arch: `
<kanban sample="1">
<field name="product_id"/>
<templates>
<div t-name="kanban-box">
<field name="foo"/>
</div>
</templates>
</kanban>`,
data: this.data,
groupBy: ['product_id'],
model: 'partner',
View: KanbanView,
viewOptions: {
action: {
help: "No content helper",
},
},
});
assert.containsNone(kanban, '.o_view_nocontent');
assert.containsOnce(kanban, '.o_quick_create_unfolded');
assert.containsOnce(kanban, '.o_kanban_example_background_container');
kanban.destroy();
});
QUnit.test('empty grouped kanban with sample data and click quick create', async function (assert) {
assert.expect(11);
const kanban = await createView({
View: KanbanView,
model: 'partner',
data: this.data,
arch: `
<kanban sample="1">
<field name="product_id"/>
<templates>
<t t-name="kanban-box">
<div><field name="foo"/></div>
</t>
</templates>
</kanban>`,
groupBy: ['product_id'],
async mockRPC(route, { kwargs, method }) {
const result = await this._super(...arguments);
if (method === 'web_read_group') {
// override read_group to return empty groups, as this is
// the case for several models (e.g. project.task grouped
// by stage_id)
result.groups.forEach(group => {
group[`${kwargs.groupby[0]}_count`] = 0;
});
}
return result;
},
viewOptions: {
action: {
help: "No content helper",
},
},
});
assert.containsN(kanban, '.o_kanban_group', 2,
"there should be two columns");
assert.hasClass(kanban.$el, 'o_view_sample_data');
assert.containsOnce(kanban, '.o_view_nocontent');
assert.containsN(kanban, '.o_kanban_record', 16,
"there should be 8 sample records by column");
await testUtils.dom.click(kanban.$('.o_kanban_quick_add:first'));
assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
assert.containsNone(kanban, '.o_kanban_record');
assert.containsNone(kanban, '.o_view_nocontent');
assert.containsOnce(kanban.$('.o_kanban_group:first'), '.o_kanban_quick_create');
await testUtils.fields.editInput(kanban.$('.o_kanban_quick_create .o_input'), 'twilight sparkle');
await testUtils.dom.click(kanban.$('.o_kanban_quick_create button.o_kanban_add'));
assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
assert.containsOnce(kanban.$('.o_kanban_group:first'), '.o_kanban_record');
assert.containsNone(kanban, '.o_view_nocontent');
kanban.destroy();
});
QUnit.test('empty grouped kanban with sample data and cancel quick create', async function (assert) {
assert.expect(12);
const kanban = await createView({
View: KanbanView,
model: 'partner',
data: this.data,
arch: `
<kanban sample="1">
<field name="product_id"/>
<templates>
<t t-name="kanban-box">
<div><field name="foo"/></div>
</t>
</templates>
</kanban>`,
groupBy: ['product_id'],
async mockRPC(route, { kwargs, method }) {
const result = await this._super(...arguments);
if (method === 'web_read_group') {
// override read_group to return empty groups, as this is
// the case for several models (e.g. project.task grouped
// by stage_id)
result.groups.forEach(group => {
group[`${kwargs.groupby[0]}_count`] = 0;
});
}
return result;
},
viewOptions: {
action: {
help: "No content helper",
},
},
});
assert.containsN(kanban, '.o_kanban_group', 2,
"there should be two columns");
assert.hasClass(kanban.$el, 'o_view_sample_data');
assert.containsOnce(kanban, '.o_view_nocontent');
assert.containsN(kanban, '.o_kanban_record', 16,
"there should be 8 sample records by column");
await testUtils.dom.click(kanban.$('.o_kanban_quick_add:first'));
assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
assert.containsNone(kanban, '.o_kanban_record');
assert.containsNone(kanban, '.o_view_nocontent');
assert.containsOnce(kanban.$('.o_kanban_group:first'), '.o_kanban_quick_create');
await testUtils.dom.click(kanban.$('.o_kanban_view'));
assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
assert.containsNone(kanban, '.o_kanban_quick_create');
assert.containsNone(kanban, '.o_kanban_record');
assert.containsOnce(kanban, '.o_view_nocontent');
kanban.destroy();
});
QUnit.test('empty kanban with sample data', async function (assert) {
assert.expect(6);
this.data.partner.records = [];
const kanban = await createView({
View: KanbanView,
model: 'partner',
data: this.data,
arch: `
<kanban sample="1">
<field name="product_id"/>
<templates>
<t t-name="kanban-box">
<div><field name="foo"/></div>
</t>
</templates>
</kanban>`,
viewOptions: {
action: {
help: "No content helper",
},
},
});
assert.hasClass(kanban.$el, 'o_view_sample_data');
assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 10,
"there should be 10 sample records");
assert.containsOnce(kanban, '.o_view_nocontent');
await kanban.reload({ domain: [['id', '<', 0]]});
assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
assert.containsNone(kanban, '.o_kanban_record:not(.o_kanban_ghost)');
assert.containsOnce(kanban, '.o_view_nocontent');
kanban.destroy();
});
QUnit.test('empty grouped kanban with sample data and many2many_tags', async function (assert) {
assert.expect(6);
const kanban = await createView({
View: KanbanView,
model: 'partner',
data: this.data,
arch: `
<kanban sample="1">
<field name="product_id"/>
<templates>
<t t-name="kanban-box">
<div>
<field name="int_field"/>
<field name="category_ids" widget="many2many_tags"/>
</div>
</t>
</templates>
</kanban>`,
groupBy: ['product_id'],
async mockRPC(route, { kwargs, method }) {
assert.step(method || route);
const result = await this._super(...arguments);
if (method === 'web_read_group') {
// override read_group to return empty groups, as this is
// the case for several models (e.g. project.task grouped
// by stage_id)
result.groups.forEach(group => {
group[`${kwargs.groupby[0]}_count`] = 0;
});
}
return result;
},
});
assert.containsN(kanban, '.o_kanban_group', 2, "there should be 2 'real' columns");
assert.hasClass(kanban.$el, 'o_view_sample_data');
assert.ok(kanban.$('.o_kanban_record').length >= 1, "there should be sample records");
assert.ok(kanban.$('.o_field_many2manytags .o_tag').length >= 1, "there should be tags");
assert.verifySteps(["web_read_group"], "should not read the tags");
kanban.destroy();
});
QUnit.test('sample data does not change after reload with sample data', async function (assert) {
assert.expect(4);
const kanban = await createView({
View: KanbanView,
model: 'partner',
data: this.data,
arch: `
<kanban sample="1">
<field name="product_id"/>
<templates>
<t t-name="kanban-box">
<div><field name="int_field"/></div>
</t>
</templates>
</kanban>`,
groupBy: ['product_id'],
async mockRPC(route, { kwargs, method }) {
const result = await this._super(...arguments);
if (method === 'web_read_group') {
// override read_group to return empty groups, as this is
// the case for several models (e.g. project.task grouped
// by stage_id)
result.groups.forEach(group => {
group[`${kwargs.groupby[0]}_count`] = 0;
});
}
return result;
},
});
const columns = kanban.el.querySelectorAll('.o_kanban_group');
assert.ok(columns.length >= 1, "there should be at least 1 sample column");
assert.hasClass(kanban.$el, 'o_view_sample_data');
assert.containsN(kanban, '.o_kanban_record', 16);
const kanbanText = kanban.el.innerText;
await kanban.reload();
assert.strictEqual(kanbanText, kanban.el.innerText,
"the content should be the same after reloading the view");
kanban.destroy();
});
QUnit.test('non empty kanban with sample data', async function (assert) {
assert.expect(5);
const kanban = await createView({
View: KanbanView,
model: 'partner',
data: this.data,
arch: `
<kanban sample="1">
<field name="product_id"/>
<templates>
<t t-name="kanban-box">
<div><field name="foo"/></div>
</t>
</templates>
</kanban>`,
viewOptions: {
action: {
help: "No content helper",
},
},
});
assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4);
assert.containsNone(kanban, '.o_view_nocontent');
await kanban.reload({ domain: [['id', '<', 0]]});
assert.doesNotHaveClass(kanban.$el, 'o_view_sample_data');
assert.containsNone(kanban, '.o_kanban_record:not(.o_kanban_ghost)');
kanban.destroy();
});
QUnit.test('empty grouped kanban with sample data: add a column', async function (assert) {
assert.expect(6);
const kanban = await createView({
arch: `
<kanban sample="1">
<field name="product_id"/>
<templates>
<div t-name="kanban-box">
<field name="foo"/>
</div>
</templates>
</kanban>`,
data: this.data,
groupBy: ['product_id'],
model: 'partner',
View: KanbanView,
async mockRPC(route, { method }) {
const result = await this._super(...arguments);
if (method === 'web_read_group') {
result.groups = this.data.product.records.map(r => {
return {
product_id: [r.id, r.display_name],
product_id_count: 0,
__domain: ['product_id', '=', r.id],
};
});
result.length = result.groups.length;
}
return result;
},
});
assert.hasClass(kanban, 'o_view_sample_data');
assert.containsN(kanban, '.o_kanban_group', 2);
assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
await testUtils.dom.click(kanban.el.querySelector('.o_kanban_add_column'));
await testUtils.fields.editInput(kanban.el.querySelector('.o_kanban_header input'), "Yoohoo");
await testUtils.dom.click(kanban.el.querySelector('.btn.o_kanban_add'));
assert.hasClass(kanban, 'o_view_sample_data');
assert.containsN(kanban, '.o_kanban_group', 3);
assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
kanban.destroy();
});
QUnit.skip('empty grouped kanban with sample data: fold/unfold a column', async function (assert) {
// folding/unfolding of grouped kanban with sample data is currently disabled
assert.expect(8);
const kanban = await createView({
arch: `
<kanban sample="1">
<field name="product_id"/>
<templates>
<div t-name="kanban-box">
<field name="foo"/>
</div>
</templates>
</kanban>`,
data: this.data,
groupBy: ['product_id'],
model: 'partner',
View: KanbanView,
async mockRPC(route, { kwargs, method }) {
const result = await this._super(...arguments);
if (method === 'web_read_group') {
// override read_group to return a single, empty group
result.groups = result.groups.slice(0, 1);
result.groups[0][`${kwargs.groupby[0]}_count`] = 0;
result.length = 1;
}
return result;
},
});
assert.hasClass(kanban, 'o_view_sample_data');
assert.containsOnce(kanban, '.o_kanban_group');
assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
// Fold the column
await testUtils.dom.click(kanban.el.querySelector('.o_kanban_config > a'));
await testUtils.dom.click(kanban.el.querySelector('.dropdown-item.o_kanban_toggle_fold'));
assert.containsOnce(kanban, '.o_kanban_group');
assert.hasClass(kanban.$('.o_kanban_group'), 'o_column_folded');
// Unfold the column
await testUtils.dom.click(kanban.el.querySelector('.o_kanban_group.o_column_folded'));
assert.containsOnce(kanban, '.o_kanban_group');
assert.doesNotHaveClass(kanban.$('.o_kanban_group'), 'o_column_folded');
assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
kanban.destroy();
});
QUnit.test('empty grouped kanban with sample data: delete a column', async function (assert) {
assert.expect(5);
this.data.partner.records = [];
let groups = [{
product_id: [1, 'New'],
product_id_count: 0,
__domain: [],
}];
const kanban = await createView({
arch: `
<kanban sample="1">
<field name="product_id"/>
<templates>
<div t-name="kanban-box">
<field name="foo"/>
</div>
</templates>
</kanban>`,
data: this.data,
groupBy: ['product_id'],
model: 'partner',
View: KanbanView,
async mockRPC(route, { method }) {
let result = await this._super(...arguments);
if (method === 'web_read_group') {
// override read_group to return a single, empty group
return {
groups,
length: groups.length,
};
}
return result;
},
});
assert.hasClass(kanban, 'o_view_sample_data');
assert.containsOnce(kanban, '.o_kanban_group');
assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
// Delete the first column
groups = [];
await testUtils.dom.click(kanban.el.querySelector('.o_kanban_config > a'));
await testUtils.dom.click(kanban.el.querySelector('.dropdown-item.o_column_delete'));
await testUtils.dom.click(document.querySelector('.modal .btn-primary'));
assert.containsNone(kanban, '.o_kanban_group');
assert.containsOnce(kanban, '.o_column_quick_create .o_quick_create_unfolded');
kanban.destroy();
});
QUnit.test('empty grouped kanban with sample data: add a column and delete it right away', async function (assert) {
assert.expect(9);
const kanban = await createView({
arch: `
<kanban sample="1">
<field name="product_id"/>
<templates>
<div t-name="kanban-box">
<field name="foo"/>
</div>
</templates>
</kanban>`,
data: this.data,
groupBy: ['product_id'],
model: 'partner',
View: KanbanView,
async mockRPC(route, { method }) {
const result = await this._super(...arguments);
if (method === 'web_read_group') {
result.groups = this.data.product.records.map(r => {
return {
product_id: [r.id, r.display_name],
product_id_count: 0,
__domain: ['product_id', '=', r.id],
};
});
result.length = result.groups.length;
}
return result;
},
});
assert.hasClass(kanban, 'o_view_sample_data');
assert.containsN(kanban, '.o_kanban_group', 2);
assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
// add a new column
await testUtils.dom.click(kanban.el.querySelector('.o_kanban_add_column'));
await testUtils.fields.editInput(kanban.el.querySelector('.o_kanban_header input'), "Yoohoo");
await testUtils.dom.click(kanban.el.querySelector('.btn.o_kanban_add'));
assert.hasClass(kanban, 'o_view_sample_data');
assert.containsN(kanban, '.o_kanban_group', 3);
assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
// delete the column we just created
const newColumn = kanban.el.querySelectorAll('.o_kanban_group')[2];
await testUtils.dom.click(newColumn.querySelector('.o_kanban_config > a'));
await testUtils.dom.click(newColumn.querySelector('.dropdown-item.o_column_delete'));
await testUtils.dom.click(document.querySelector('.modal .btn-primary'));
assert.hasClass(kanban, 'o_view_sample_data');
assert.containsN(kanban, '.o_kanban_group', 2);
assert.ok(kanban.$('.o_kanban_record').length > 0, 'should contain sample records');
kanban.destroy();
});
QUnit.test('bounce create button when no data and click on empty area', async function (assert) {
assert.expect(2);
@@ -4535,7 +5059,6 @@ QUnit.module('Views', {
await testUtils.dom.click(kanban.$('.o_column_quick_create'));
kanban.$('.o_column_quick_create input').val('new column');
await testUtils.dom.click(kanban.$('.o_column_quick_create button.o_kanban_add'));
assert.isVisible(kanban.$buttons.find('.o-kanban-button-new'),
"Create button should now be visible");
kanban.destroy();
+279
View File
@@ -5,6 +5,7 @@ var AbstractFieldOwl = require('web.AbstractFieldOwl');
var AbstractStorageService = require('web.AbstractStorageService');
var BasicModel = require('web.BasicModel');
var core = require('web.core');
const Domain = require('web.Domain')
var basicFields = require('web.basic_fields');
var fieldRegistry = require('web.field_registry');
var fieldRegistryOwl = require('web.field_registry_owl');
@@ -3764,6 +3765,284 @@ QUnit.module('Views', {
list.destroy();
});
QUnit.test("empty list with sample data", async function (assert) {
assert.expect(19);
const list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: `
<tree sample="1">
<field name="foo"/>
<field name="bar"/>
<field name="int_field"/>
<field name="m2o"/>
<field name="m2m" widget="many2many_tags"/>
<field name="date"/>
<field name="datetime"/>
</tree>`,
domain: [['id', '<', 0]], // such that no record matches the domain
viewOptions: {
action: {
help: '<p class="hello">click to add a partner</p>'
}
},
});
assert.hasClass(list.$el, 'o_view_sample_data');
assert.containsOnce(list, '.o_list_table');
assert.containsN(list, '.o_data_row', 10);
assert.containsOnce(list, '.o_nocontent_help .hello');
// Check list sample data
const firstRow = list.el.querySelector('.o_data_row');
const cells = firstRow.querySelectorAll(':scope > .o_data_cell');
assert.strictEqual(cells[0].innerText.trim(), "",
"Char field should yield an empty element"
);
assert.containsOnce(cells[1], '.custom-checkbox',
"Boolean field has been instantiated"
);
assert.notOk(isNaN(cells[2].innerText.trim()), "Intger value is a number");
assert.ok(cells[3].innerText.trim(), "Many2one field is a string");
const firstM2MTag = cells[4].querySelector(
':scope span.o_badge_text'
).innerText.trim();
assert.ok(firstM2MTag.length > 0, "Many2many contains at least one string tag");
assert.ok(/\d{2}\/\d{2}\/\d{4}/.test(cells[5].innerText.trim()),
"Date field should have the right format"
);
assert.ok(/\d{2}\/\d{2}\/\d{4} \d{2}:\d{2}:\d{2}/.test(cells[6].innerText.trim()),
"Datetime field should have the right format"
);
const textContent = list.$el.text();
await list.reload();
assert.strictEqual(textContent, list.$el.text(),
'The content should be the same after reloading the view without change'
);
// reload with another domain -> should no longer display the sample records
await list.reload({ domain: Domain.FALSE_DOMAIN });
assert.doesNotHaveClass(list.$el, 'o_view_sample_data');
assert.containsNone(list, '.o_list_table');
assert.containsOnce(list, '.o_nocontent_help .hello');
// reload with another domain matching records
await list.reload({ domain: Domain.TRUE_DOMAIN });
assert.doesNotHaveClass(list.$el, 'o_view_sample_data');
assert.containsOnce(list, '.o_list_table');
assert.containsN(list, '.o_data_row', 4);
assert.containsNone(list, '.o_nocontent_help .hello');
list.destroy();
});
QUnit.test("non empty list with sample data", async function (assert) {
assert.expect(6);
const list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: `
<tree sample="1">
<field name="foo"/>
<field name="bar"/>
<field name="int_field"/>
</tree>`,
domain: Domain.TRUE_DOMAIN,
});
assert.containsOnce(list, '.o_list_table');
assert.containsN(list, '.o_data_row', 4);
assert.doesNotHaveClass(list.$el, 'o_view_sample_data');
// reload with another domain matching no record (should not display the sample records)
await list.reload({ domain: Domain.FALSE_DOMAIN });
assert.containsOnce(list, '.o_list_table');
assert.containsNone(list, '.o_data_row');
assert.doesNotHaveClass(list.$el, 'o_view_sample_data');
list.destroy();
});
QUnit.test('click on header in empty list with sample data', async function (assert) {
assert.expect(4);
const list = await createView({
View: ListView,
model: 'foo',
data: this.data,
arch: `
<tree sample="1">
<field name="foo"/>
<field name="bar"/>
<field name="int_field"/>
</tree>`,
domain: Domain.FALSE_DOMAIN,
});
assert.hasClass(list, 'o_view_sample_data');
assert.containsOnce(list, '.o_list_table');
assert.containsN(list, '.o_data_row', 10);
const content = list.$el.text();
await testUtils.dom.click(list.$('tr:first .o_column_sortable:first'));
assert.strictEqual(list.$el.text(), content, "the content should still be the same");
list.destroy();
});
QUnit.test("non empty editable list with sample data: delete all records", async function (assert) {
assert.expect(7);
const list = await createView({
arch: `
<tree editable="top" sample="1">
<field name="foo"/>
<field name="bar"/>
<field name="int_field"/>
</tree>`,
data: this.data,
domain: Domain.TRUE_DOMAIN,
model: 'foo',
View: ListView,
viewOptions: {
action: {
help: '<p class="hello">click to add a partner</p>'
},
hasActionMenus: true,
},
});
// Initial state: all records displayed
assert.doesNotHaveClass(list, 'o_view_sample_data');
assert.containsOnce(list, '.o_list_table');
assert.containsN(list, '.o_data_row', 4);
assert.containsNone(list, '.o_nocontent_help');
// Delete all records
await testUtils.dom.click(list.el.querySelector('thead .o_list_record_selector input'));
await cpHelpers.toggleActionMenu(list);
await cpHelpers.toggleMenuItem(list, "Delete");
await testUtils.dom.click($('.modal-footer .btn-primary'));
// Final state: no more sample data, but nocontent helper displayed
assert.doesNotHaveClass(list, 'o_view_sample_data');
assert.containsNone(list, '.o_list_table');
assert.containsOnce(list, '.o_nocontent_help');
list.destroy();
});
QUnit.test("empty editable list with sample data: start create record and cancel", async function (assert) {
assert.expect(10);
const list = await createView({
arch: `
<tree editable="top" sample="1">
<field name="foo"/>
<field name="bar"/>
<field name="int_field"/>
</tree>`,
data: this.data,
domain: Domain.FALSE_DOMAIN,
model: 'foo',
View: ListView,
viewOptions: {
action: {
help: '<p class="hello">click to add a partner</p>'
},
},
});
// Initial state: sample data and nocontent helper displayed
assert.hasClass(list, 'o_view_sample_data');
assert.containsOnce(list, '.o_list_table');
assert.containsN(list, '.o_data_row', 10);
assert.containsOnce(list, '.o_nocontent_help');
// Start creating a record
await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_add'));
assert.doesNotHaveClass(list, 'o_view_sample_data');
assert.containsOnce(list, '.o_data_row');
// Discard temporary record
await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_discard'));
// Final state: table should be displayed with no data at all
assert.doesNotHaveClass(list, 'o_view_sample_data');
assert.containsOnce(list, '.o_list_table');
assert.containsNone(list, '.o_data_row');
assert.containsNone(list, '.o_nocontent_help');
list.destroy();
});
QUnit.test("empty editable list with sample data: create and delete record", async function (assert) {
assert.expect(13);
const list = await createView({
arch: `
<tree editable="top" sample="1">
<field name="foo"/>
<field name="bar"/>
<field name="int_field"/>
</tree>`,
data: this.data,
domain: Domain.FALSE_DOMAIN,
model: 'foo',
View: ListView,
viewOptions: {
action: {
help: '<p class="hello">click to add a partner</p>'
},
hasActionMenus: true,
},
});
// Initial state: sample data and nocontent helper displayed
assert.hasClass(list, 'o_view_sample_data');
assert.containsOnce(list, '.o_list_table');
assert.containsN(list, '.o_data_row', 10);
assert.containsOnce(list, '.o_nocontent_help');
// Start creating a record
await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_add'));
assert.doesNotHaveClass(list, 'o_view_sample_data');
assert.containsOnce(list, '.o_data_row');
// Save temporary record
await testUtils.dom.click(list.el.querySelector('.btn.o_list_button_save'));
assert.doesNotHaveClass(list, 'o_view_sample_data');
assert.containsOnce(list, '.o_list_table');
assert.containsOnce(list, '.o_data_row');
assert.containsNone(list, '.o_nocontent_help');
// Delete newly created record
await testUtils.dom.click(list.el.querySelector('.o_data_row input'));
await cpHelpers.toggleActionMenu(list);
await cpHelpers.toggleMenuItem(list, "Delete");
await testUtils.dom.click($('.modal-footer .btn-primary'));
// Final state: there should be no table, but the no content helper
assert.doesNotHaveClass(list, 'o_view_sample_data');
assert.containsNone(list, '.o_list_table');
assert.containsOnce(list, '.o_nocontent_help');
list.destroy();
});
QUnit.test('groupby node with a button', async function (assert) {
assert.expect(14);
@@ -0,0 +1,461 @@
odoo.define('web.sample_server_tests', function (require) {
"use strict";
const SampleServer = require('web.SampleServer');
const session = require('web.session');
const { mock } = require('web.test_utils');
const {
MAIN_RECORDSET_SIZE, SEARCH_READ_LIMIT, // Limits
SAMPLE_COUNTRIES, SAMPLE_PEOPLE, SAMPLE_TEXTS, // Text values
MAX_COLOR_INT, MAX_FLOAT, MAX_INTEGER, MAX_MONETARY // Number values
} = SampleServer;
QUnit.module("Sample Server", {
beforeEach() {
this.fields = {
'res.users': {
display_name: { string: "Name", type: 'char' },
name: { string: "Reference", type: 'char' },
email: { string: "Email", type: 'char' },
phone_number: { string: "Phone number", type: 'char' },
brol_machin_url_truc: { string: "URL", type: 'char' },
urlemailphone: { string: "Whatever", type: 'char' },
active: { string: "Active", type: 'boolean' },
is_alive: { string: "Is alive", type: 'boolean' },
description: { string: "Description", type: 'text' },
birthday: { string: "Birthday", type: 'date' },
arrival_date: { string: "Date of arrival", type: 'datetime' },
height: { string: "Height", type: 'float' },
color: { string: "Color", type: 'integer' },
age: { string: "Age", type: 'integer' },
salary: { string: "Salary", type: 'monetary' },
currency: { string: "Currency", type: 'many2one', relation: 'res.currency' },
manager_id: { string: "Manager", type: 'many2one', relation: 'res.users' },
managed_ids: { string: "Managing", type: 'one2many', relation: 'res.users' },
tag_ids: { string: "Tags", type: 'many2many', relation: 'tag' },
type: { string: "Type", type: 'selection', selection: [
['client', "Client"], ['partner', "Partner"], ['employee', "Employee"]
] },
},
'res.country': {
display_name: { string: "Name", type: 'char' },
},
'hobbit': {
display_name: { string: "Name", type: 'char' },
profession: { string: "Profession", type: 'selection', selection: [
['gardener', "Gardener"], ['brewer', "Brewer"], ['adventurer', "Adventurer"]
] },
age: { string: "Age", type: 'integer' },
},
};
},
}, function () {
QUnit.module("Basic behaviour");
QUnit.test("Sample data: people type + all field names", async function (assert) {
assert.expect(24);
mock.patch(session, {
company_currency_id: 4,
});
const allFieldNames = Object.keys(this.fields['res.users']);
const server = new SampleServer('res.users', this.fields['res.users']);
const { records } = await server.mockRpc({
method: '/web/dataset/search_read',
model: 'res.users',
fields: allFieldNames,
});
const rec = records[0];
function assertFormat(fieldName, regex) {
if (regex instanceof RegExp) {
assert.ok(
regex.test(rec[fieldName].toString()),
`Field "${fieldName}" has the correct format`
);
} else {
assert.strictEqual(
typeof rec[fieldName], regex,
`Field "${fieldName}" is of type ${regex}`
);
}
}
function assertBetween(fieldName, min, max, decimal = 1) {
assert.ok(
min <= rec[fieldName] && rec[fieldName] < max &&
rec[fieldName].toString().split(".").length === decimal,
`Field "${fieldName}" is between ${min} and ${max} and is ${
decimal === 1 ? "an integer" : "a float number"
}`
);
}
// Basic fields
assert.ok(SAMPLE_PEOPLE.includes(rec.display_name));
assert.ok(SAMPLE_PEOPLE.includes(rec.name));
assert.strictEqual(rec.email,
`${rec.display_name.replace(/ /, ".").toLowerCase()}@sample.demo`
);
assertFormat('phone_number', /\+1 555 754 000\d/);
assertFormat('brol_machin_url_truc', /http:\/\/sample\d\.com/);
assert.strictEqual(rec.urlemailphone, false);
assert.strictEqual(rec.active, true);
assertFormat('is_alive', 'boolean');
assert.ok(SAMPLE_TEXTS.includes(rec.description));
assertFormat('birthday', /\d{4}-\d{2}-\d{2}/);
assertFormat('arrival_date', /\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/);
assertBetween('height', 0, MAX_FLOAT, 2);
assertBetween('color', 0, MAX_COLOR_INT);
assertBetween('age', 0, MAX_INTEGER);
assertBetween('salary', 0, MAX_MONETARY);
const selectionValues = this.fields['res.users'].type.selection.map(
(sel) => sel[0]
);
assert.ok(selectionValues.includes(rec.type));
// Relational fields
assert.strictEqual(rec.currency[0], 4);
// Currently we expect the currency name to be a latin string, which
// is not important; in most case we only need the ID. The following
// assertion can be removed if needed.
assert.ok(SAMPLE_TEXTS.includes(rec.currency[1]));
assert.strictEqual(typeof rec.manager_id[0], 'number');
assert.ok(SAMPLE_PEOPLE.includes(rec.manager_id[1]));
assert.ok([1, 2].includes(rec.managed_ids.length));
assert.ok(rec.managed_ids.every(
(id) => typeof id === 'number')
);
assert.ok([1, 2].includes(rec.tag_ids.length));
assert.ok(rec.tag_ids.every(
(id) => typeof id === 'number')
);
mock.unpatch(session);
});
QUnit.test("Sample data: country type", async function (assert) {
assert.expect(1);
const server = new SampleServer('res.country', this.fields['res.country']);
const { records } = await server.mockRpc({
method: '/web/dataset/search_read',
model: 'res.country',
fields: ['display_name'],
});
assert.ok(SAMPLE_COUNTRIES.includes(records[0].display_name));
});
QUnit.test("Sample data: any type", async function (assert) {
assert.expect(1);
const server = new SampleServer('hobbit', this.fields.hobbit);
const { records } = await server.mockRpc({
method: '/web/dataset/search_read',
model: 'hobbit',
fields: ['display_name'],
});
assert.ok(SAMPLE_TEXTS.includes(records[0].display_name));
});
QUnit.module("RPC calls");
QUnit.test("Send 'search_read' RPC: valid field names", async function (assert) {
assert.expect(3);
const server = new SampleServer('hobbit', this.fields.hobbit);
const result = await server.mockRpc({
method: '/web/dataset/search_read',
model: 'hobbit',
fields: ['display_name'],
});
assert.deepEqual(
Object.keys(result.records[0]),
['id', 'display_name']
);
assert.strictEqual(result.length, SEARCH_READ_LIMIT);
assert.ok(/\w+/.test(result.records[0].display_name),
"Display name has been mocked"
);
});
QUnit.test("Send 'search_read' RPC: invalid field names", async function (assert) {
assert.expect(3);
const server = new SampleServer('hobbit', this.fields.hobbit);
const result = await server.mockRpc({
method: '/web/dataset/search_read',
model: 'hobbit',
fields: ['name'],
});
assert.deepEqual(
Object.keys(result.records[0]),
['id', 'name']
);
assert.strictEqual(result.length, SEARCH_READ_LIMIT);
assert.strictEqual(result.records[0].name, false,
`Field "name" doesn't exist => returns false`
);
});
QUnit.test("Send 'web_read_group' RPC: no group", async function (assert) {
assert.expect(1);
const server = new SampleServer('hobbit', this.fields.hobbit);
server.setExistingGroups([]);
const result = await server.mockRpc({
method: 'web_read_group',
model: 'hobbit',
groupBy: ['profession'],
});
assert.deepEqual(result, { groups: [], length: 0 });
});
QUnit.test("Send 'web_read_group' RPC: 2 groups", async function (assert) {
assert.expect(6);
const server = new SampleServer('hobbit', this.fields.hobbit);
const existingGroups = [
{ profession: 'gardener', profession_count: 0 },
{ profession: 'adventurer', profession_count: 0 },
];
server.setExistingGroups(existingGroups);
const result = await server.mockRpc({
method: 'web_read_group',
model: 'hobbit',
groupBy: ['profession'],
fields: [],
});
assert.strictEqual(result.length, 2);
assert.strictEqual(result.groups.length, 2);
const professions = result.groups.map((g) => g.profession);
assert.ok(professions.includes("gardener"));
assert.ok(professions.includes("adventurer"));
assert.strictEqual(
result.groups.reduce((acc, g) => acc + g.profession_count, 0),
MAIN_RECORDSET_SIZE
);
assert.ok(
result.groups.every((g) => g.profession_count === g.__data.length)
);
});
QUnit.test("Send 'web_read_group' RPC: all groups", async function (assert) {
assert.expect(7);
const server = new SampleServer('hobbit', this.fields.hobbit);
const existingGroups = [
{ profession: 'gardener', profession_count: 0 },
{ profession: 'brewer', profession_count: 0 },
{ profession: 'adventurer', profession_count: 0 },
];
server.setExistingGroups(existingGroups);
const result = await server.mockRpc({
method: 'web_read_group',
model: 'hobbit',
groupBy: ['profession'],
fields: [],
});
assert.strictEqual(result.length, 3);
assert.strictEqual(result.groups.length, 3);
const professions = result.groups.map((g) => g.profession);
assert.ok(professions.includes("gardener"));
assert.ok(professions.includes("brewer"));
assert.ok(professions.includes("adventurer"));
assert.strictEqual(
result.groups.reduce((acc, g) => acc + g.profession_count, 0),
MAIN_RECORDSET_SIZE
);
assert.ok(
result.groups.every((g) => g.profession_count === g.__data.length)
);
});
QUnit.test("Send 'read_group' RPC: no group", async function (assert) {
assert.expect(1);
const server = new SampleServer('hobbit', this.fields.hobbit);
const result = await server.mockRpc({
method: 'read_group',
model: 'hobbit',
fields: [],
groupBy: [],
});
assert.deepEqual(result, [{
__count: MAIN_RECORDSET_SIZE,
__domain: [],
}]);
});
QUnit.test("Send 'read_group' RPC: groupBy", async function (assert) {
assert.expect(5);
const server = new SampleServer('hobbit', this.fields.hobbit);
const result = await server.mockRpc({
method: 'read_group',
model: 'hobbit',
fields: [],
groupBy: ['profession'],
});
assert.strictEqual(result.length, 3);
const professions = result.map((g) => g.profession);
assert.ok(professions.includes("gardener"));
assert.ok(professions.includes("brewer"));
assert.ok(professions.includes("adventurer"));
assert.strictEqual(
result.reduce((acc, g) => acc + g.profession_count, 0),
MAIN_RECORDSET_SIZE,
);
});
QUnit.test("Send 'read_group' RPC: groupBy and field", async function (assert) {
assert.expect(6);
const server = new SampleServer('hobbit', this.fields.hobbit);
const result = await server.mockRpc({
method: 'read_group',
model: 'hobbit',
fields: ['age'],
groupBy: ['profession'],
});
assert.strictEqual(result.length, 3);
const professions = result.map((g) => g.profession);
assert.ok(professions.includes("gardener"));
assert.ok(professions.includes("brewer"));
assert.ok(professions.includes("adventurer"));
assert.strictEqual(
result.reduce((acc, g) => acc + g.profession_count, 0),
MAIN_RECORDSET_SIZE,
);
assert.strictEqual(
result.reduce((acc, g) => acc + g.age, 0),
server.data.hobbit.records.reduce((acc, g) => acc + g.age, 0)
);
});
QUnit.test("Send 'read_group' RPC: multiple groupBys and lazy", async function (assert) {
assert.expect(2);
const server = new SampleServer('hobbit', this.fields.hobbit);
const result = await server.mockRpc({
method: 'read_group',
model: 'hobbit',
fields: [],
groupBy: ['profession', 'age'],
});
assert.ok('profession' in result[0]);
assert.notOk('age' in result[0]);
});
QUnit.test("Send 'read_group' RPC: multiple groupBys and not lazy", async function (assert) {
assert.expect(2);
const server = new SampleServer('hobbit', this.fields.hobbit);
const result = await server.mockRpc({
method: 'read_group',
model: 'hobbit',
fields: [],
groupBy: ['profession', 'age'],
lazy: false,
});
assert.ok('profession' in result[0]);
assert.ok('age' in result[0]);
});
QUnit.test("Send 'read' RPC: no id", async function (assert) {
assert.expect(1);
const server = new SampleServer('hobbit', this.fields.hobbit);
const result = await server.mockRpc({
method: 'read',
model: 'hobbit',
args: [
[], ['display_name']
],
});
assert.deepEqual(result, []);
});
QUnit.test("Send 'read' RPC: one id", async function (assert) {
assert.expect(3);
const server = new SampleServer('hobbit', this.fields.hobbit);
const result = await server.mockRpc({
method: 'read',
model: 'hobbit',
args: [
[1], ['display_name']
],
});
assert.strictEqual(result.length, 1);
assert.ok(
/\w+/.test(result[0].display_name),
"Display name has been mocked"
);
assert.strictEqual(result[0].id, 1);
});
QUnit.test("Send 'read' RPC: more than all available ids", async function (assert) {
assert.expect(1);
const server = new SampleServer('hobbit', this.fields.hobbit);
const amount = MAIN_RECORDSET_SIZE + 3;
const ids = new Array(amount).fill().map((_, i) => i + 1);
const result = await server.mockRpc({
method: 'read',
model: 'hobbit',
args: [
ids, ['display_name']
],
});
assert.strictEqual(result.length, MAIN_RECORDSET_SIZE);
});
// To be implemented if needed
// QUnit.test("Send 'read_progress_bar' RPC", async function (assert) { ... });
});
});
+3
View File
@@ -337,6 +337,7 @@
<script type="text/javascript" src="/web/static/src/js/views/file_upload_mixin.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/file_upload_progress_bar.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/file_upload_progress_card.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/sample_server.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/select_create_controllers_registry.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/signature_dialog.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/standalone_field_manager_mixin.js"></script>
@@ -701,6 +702,7 @@
<script type="text/javascript" src="/web/static/tests/fields/special_fields_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/fields/upgrade_fields_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/sample_server_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/abstract_controller_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/abstract_view_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/form_tests.js"></script>
@@ -710,6 +712,7 @@
<script type="text/javascript" src="/web/static/tests/views/kanban_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/calendar_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/qweb_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/abstract_model_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/basic_model_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/abstract_view_banner_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/kanban_model_tests.js"></script>
+12
View File
@@ -121,6 +121,18 @@ an impact on all view types.
``info``, ``warning``, ``danger`` and ``secondary`` displays. The list view supports ``bf``,
``it``, ``success``, ``info``, ``warning``, ``danger``, ``muted`` and ``primary`` displays.
* ``sample`` (``kanban`` & ``list`` & ``gantt``)
Populate the view with a set of sample records if none are found for the current model.
This attribute is false by default.
These fake records will have heuristics for certain field names/models. For example,
a field 'display_name' on the model 'res.users' will be populated with sample people names
while an 'email' field will be in the form 'firstname.lastname@sample.demo'.
The user will not be able to interact with these data and they will be discarded as soon as
an action is performed (record created, column added, etc.)
* ``banner_route``
a route address to be fetched and prepended to the view.