From a882b725bb6bf3d11c898a69e3fa5502802c1b78 Mon Sep 17 00:00:00 2001 From: FrancoisGe Date: Wed, 9 Nov 2022 14:15:37 +0000 Subject: [PATCH] [REF] web: adds the callback onWillSaveRecord and onRecordSaved The purpose of this commit is to add extension points that allow code to be executed before and after the save of a record. CallBack: onWillSaveRecord is a callBack that will be executed before the record save if the record is valid if the record is valid. If it returns false, it will prevent the save. onRecordSaved is a callBack that will be executed after the save if it was done. It will therefore not be executed if the record is invalid or if a server error is thrown. This commit will replace all the save overrides by the onWillSaveRecord and onRecordSaved callBack. Observed problem: We could notice that each of the overrides of save in order to execute for example a doAction after this one did not take into account the fact that the save did not succeed. For example, a required field is invalid and we try to save, then the doAction will be executed without the "write" of the save. The callBack onRecordSaved will prevent this common error. closes odoo/odoo#105180 Related: odoo/enterprise#33710 Signed-off-by: Aaron Bohy (aab) --- .../crm/static/src/views/crm_form/crm_form.js | 46 +++--- .../js/event_booth_configurator_controller.js | 47 ++++++ .../src/js/event_booth_configurator_model.js | 47 ------ .../src/js/event_configurator_controller.js | 46 ++++++ .../static/src/js/event_configurator_model.js | 46 ------ .../hr/static/src/views/profile_form_view.js | 25 ++- .../src/views/basic_relational_model.js | 13 ++ .../static/src/views/form/form_controller.js | 18 +++ .../static/src/views/list/list_controller.js | 18 +++ .../web/static/src/views/relational_model.js | 59 ++++--- .../tests/views/form/form_view_tests.js | 89 +++++++++- .../web/static/tests/views/list_view_tests.js | 152 ++++++++++++++++-- 12 files changed, 443 insertions(+), 163 deletions(-) create mode 100644 addons/event_booth_sale/static/src/js/event_booth_configurator_controller.js delete mode 100644 addons/event_booth_sale/static/src/js/event_booth_configurator_model.js create mode 100644 addons/event_sale/static/src/js/event_configurator_controller.js delete mode 100644 addons/event_sale/static/src/js/event_configurator_model.js diff --git a/addons/crm/static/src/views/crm_form/crm_form.js b/addons/crm/static/src/views/crm_form/crm_form.js index 57580f55993..6b08c6a4d62 100644 --- a/addons/crm/static/src/views/crm_form/crm_form.js +++ b/addons/crm/static/src/views/crm_form/crm_form.js @@ -1,9 +1,9 @@ /** @odoo-module **/ -import { registry } from "@web/core/registry"; -import { formView } from "@web/views/form/form_view"; import { checkRainbowmanMessage } from "@crm/views/check_rainbowman_message"; -import { Record, RelationalModel } from "@web/views/basic_relational_model"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { formView } from "@web/views/form/form_view"; /** * This Form Controller makes sure we display a rainbowman message @@ -12,7 +12,15 @@ import { Record, RelationalModel } from "@web/views/basic_relational_model"; * if the lead is won and if a message should be displayed to the user * with a rainbowman like when the user click on the button "Mark Won". */ -export class CrmFormRecord extends Record { + +class CrmFormController extends formView.Controller { + setup() { + super.setup(); + this.orm = useService("orm"); + this.effect = useService("effect"); + this.changedStage = false; + } + /** * Main method used when saving the record hitting the "Save" button. * We check if the stage_id field was altered and if we need to display a rainbowman @@ -30,9 +38,9 @@ export class CrmFormRecord extends Record { * * @override */ - async save() { - const recordID = this.__bm_handle__; - const localData = this.model.__bm__.localData[recordID]; + async onWillSaveRecord(record) { + const recordID = record.__bm_handle__; + const localData = record.model.__bm__.localData[recordID]; const changes = localData._changes || {}; const needsSynchronizationEmail = @@ -58,34 +66,26 @@ export class CrmFormRecord extends Record { if (!localData._changes && Object.keys(changes).length) { localData._changes = changes; } - let changedStage = false; + if ("stage_id" in changes) { - const bm = this.model.__bm__; + const bm = record.model.__bm__; let oldStageId = false; if (bm.localData[recordID].data.stage_id) { oldStageId = bm.get(bm.localData[recordID].data.stage_id).data.id; } const newStageId = bm.get(bm.localData[recordID]._changes.stage_id).data.id; - changedStage = oldStageId !== newStageId; + this.changedStage = oldStageId !== newStageId; } - const isSaved = await super.save(...arguments); - if (changedStage && isSaved) { - await checkRainbowmanMessage(this.model.orm, this.model.effect, this.resId); - } - return isSaved; } -} -class CrmFormModel extends RelationalModel { - setup(params, services) { - this.effect = services.effect; - super.setup(...arguments); + async onRecordSaved(record) { + if (this.changedStage) { + await checkRainbowmanMessage(this.orm, this.effect, record.resId); + } } } -CrmFormModel.Record = CrmFormRecord; -CrmFormModel.services = [...RelationalModel.services, "effect"]; registry.category("views").add("crm_form", { ...formView, - Model: CrmFormModel, + Controller: CrmFormController, }); diff --git a/addons/event_booth_sale/static/src/js/event_booth_configurator_controller.js b/addons/event_booth_sale/static/src/js/event_booth_configurator_controller.js new file mode 100644 index 00000000000..1e1ea6722a3 --- /dev/null +++ b/addons/event_booth_sale/static/src/js/event_booth_configurator_controller.js @@ -0,0 +1,47 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { formView } from "@web/views/form/form_view"; + +/** + * This controller is overridden to allow configuring sale_order_lines through a popup + * window when a product with 'detailed_type' == 'event_booth' is selected. + * + * This allows keeping an editable list view for sales order and remove the noise of + * those 3 fields ('event_id', 'event_booth_category_id' and 'event_booth_ids') + */ + +class EventBoothConfiguratorController extends formView.Controller { + setup() { + super.setup(); + this.action = useService("action"); + } + + onRecordSaved(record) { + const { event_id, event_booth_category_id, event_booth_ids } = record.data; + return this.action.doAction({ + type: "ir.actions.act_window_close", + infos: { + eventBoothConfiguration: { + event_id, + event_booth_category_id, + event_booth_pending_ids: { + operation: "MULTI", + commands: [ + { + operation: "REPLACE_WITH", + ids: event_booth_ids.currentIds, + }, + ], + }, + }, + }, + }); + } +} + +registry.category("views").add("event_booth_configurator_form", { + ...formView, + Controller: EventBoothConfiguratorController, +}); diff --git a/addons/event_booth_sale/static/src/js/event_booth_configurator_model.js b/addons/event_booth_sale/static/src/js/event_booth_configurator_model.js deleted file mode 100644 index 082847f46e2..00000000000 --- a/addons/event_booth_sale/static/src/js/event_booth_configurator_model.js +++ /dev/null @@ -1,47 +0,0 @@ -/** @odoo-module **/ - -import { registry } from "@web/core/registry"; -import { formView } from "@web/views/form/form_view"; -import { Record, RelationalModel } from "@web/views/relational_model"; - -/** - * This model is overridden to allow configuring sale_order_lines through a popup - * window when a product with 'detailed_type' == 'event_booth' is selected. - * - * This allows keeping an editable list view for sales order and remove the noise of - * those 3 fields ('event_id', 'event_booth_category_id' and 'event_booth_ids') - */ -class EventBoothConfiguratorRelationalModel extends RelationalModel {} - -class EventBoothConfiguratorRecord extends Record { - //-------------------------------------------------------------------------- - // Overrides - //-------------------------------------------------------------------------- - async save() { - const isSaved = await super.save(...arguments); - if (!isSaved) { - return false; - } - this.model.action.doAction({type: 'ir.actions.act_window_close', infos: { - eventBoothConfiguration: { - event_id: this.data.event_id, - event_booth_category_id: this.data.event_booth_category_id, - event_booth_pending_ids: { - operation: 'MULTI', - commands: [{ - operation: 'REPLACE_WITH', - ids: this.data.event_booth_ids.currentIds, - }], - } - } - }}); - return true; - } -} - -EventBoothConfiguratorRelationalModel.Record = EventBoothConfiguratorRecord; - -registry.category("views").add("event_booth_configurator_form", { - ...formView, - Model: EventBoothConfiguratorRelationalModel, -}); diff --git a/addons/event_sale/static/src/js/event_configurator_controller.js b/addons/event_sale/static/src/js/event_configurator_controller.js new file mode 100644 index 00000000000..fac0056c865 --- /dev/null +++ b/addons/event_sale/static/src/js/event_configurator_controller.js @@ -0,0 +1,46 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { formView } from "@web/views/form/form_view"; + +/** + * This controller is overridden to allow configuring sale_order_lines through a popup + * window when a product with 'detailed_type' == 'event' is selected. + * + * This allows keeping an editable list view for sales order and remove the noise of + * those 2 fields ('event_id' + 'event_ticket_id') + */ + +class EventConfiguratorController extends formView.Controller { + setup() { + super.setup(); + this.action = useService("action"); + } + + /** + * We let the regular process take place to allow the validation of the required fields + * to happen. + * + * Then we can manually close the window, providing event information to the caller. + * + * @override + */ + onRecordSaved(record) { + const { event_id, event_ticket_id } = record.data; + return this.action.doAction({ + type: "ir.actions.act_window_close", + infos: { + eventConfiguration: { + event_id, + event_ticket_id, + }, + }, + }); + } +} + +registry.category("views").add("event_configurator_form", { + ...formView, + Controller: EventConfiguratorController, +}); diff --git a/addons/event_sale/static/src/js/event_configurator_model.js b/addons/event_sale/static/src/js/event_configurator_model.js deleted file mode 100644 index 13839eeaaff..00000000000 --- a/addons/event_sale/static/src/js/event_configurator_model.js +++ /dev/null @@ -1,46 +0,0 @@ -/** @odoo-module **/ - -import { registry } from "@web/core/registry"; -import { formView } from "@web/views/form/form_view"; -import { Record, RelationalModel } from "@web/views/relational_model"; - -/** - * This model is overridden to allow configuring sale_order_lines through a popup - * window when a product with 'detailed_type' == 'event' is selected. - * - * This allows keeping an editable list view for sales order and remove the noise of - * those 2 fields ('event_id' + 'event_ticket_id') - */ - -class EventConfiguratorRelationalModel extends RelationalModel {} - -class EventConfiguratorRecord extends Record { - /** - * We let the regular process take place to allow the validation of the required fields - * to happen. - * - * Then we can manually close the window, providing event information to the caller. - * - * @override - */ - async save() { - const isSaved = await super.save(...arguments); - if (!isSaved) { - return false; - } - this.model.action.doAction({type: 'ir.actions.act_window_close', infos: { - eventConfiguration: { - event_id: this.data.event_id, - event_ticket_id: this.data.event_ticket_id, - } - }}); - return true; - } -} - -EventConfiguratorRelationalModel.Record = EventConfiguratorRecord; - -registry.category("views").add("event_configurator_form", { - ...formView, - Model: EventConfiguratorRelationalModel, -}); diff --git a/addons/hr/static/src/views/profile_form_view.js b/addons/hr/static/src/views/profile_form_view.js index 7c8fd305343..7f087430106 100644 --- a/addons/hr/static/src/views/profile_form_view.js +++ b/addons/hr/static/src/views/profile_form_view.js @@ -1,25 +1,24 @@ /** @odoo-module */ import { registry } from "@web/core/registry"; - +import { useService } from "@web/core/utils/hooks"; import { formView } from "@web/views/form/form_view"; -import { Record, RelationalModel } from "@web/views/basic_relational_model"; -export class EmployeeProfileRecord extends Record { - async save() { - const dirtyFields = this.dirtyFields.map((f) => f.name); - const isSaved = await super.save(...arguments); - if (isSaved && dirtyFields.includes("lang")) { - this.model.actionService.doAction("reload_context"); +export class EmployeeProfileController extends formView.Controller { + setup() { + super.setup(); + this.action = useService("action"); + } + + onRecordSaved(record) { + const dirtyFields = record.dirtyFields.map((f) => f.name); + if (dirtyFields.includes("lang")) { + return this.action.doAction("reload_context"); } - return isSaved; } } -class EmployeeProfileModel extends RelationalModel {} -EmployeeProfileModel.Record = EmployeeProfileRecord; - registry.category("views").add("hr_employee_profile_form", { ...formView, - Model: EmployeeProfileModel, + Controller: EmployeeProfileController, }); diff --git a/addons/web/static/src/views/basic_relational_model.js b/addons/web/static/src/views/basic_relational_model.js index 5e75e0ed1ad..f2bd32513db 100644 --- a/addons/web/static/src/views/basic_relational_model.js +++ b/addons/web/static/src/views/basic_relational_model.js @@ -96,6 +96,9 @@ export class Record extends DataPoint { this._domains = {}; this._closeInvalidFieldsNotification = () => {}; + this.onWillSaveRecord = params.onWillSaveRecord || (() => {}); + this.onRecordSaved = params.onRecordSaved || (() => {}); + this._requiredFields = {}; for (const [fieldName, activeField] of Object.entries(this.activeFields)) { const { modifiers } = activeField; @@ -593,6 +596,9 @@ export class Record extends DataPoint { resolveSavePromise(); return false; } + if ((await this.onWillSaveRecord(this)) === false) { + return false; + } const saveOptions = { reload: !options.noReload, savePoint: options.savePoint, @@ -625,6 +631,8 @@ export class Record extends DataPoint { } this.model.notify(); resolveSavePromise(); + + await this.onRecordSaved(this); return true; } @@ -1124,6 +1132,9 @@ export class RelationalModel extends Model { }; this.initialMode = params.mode; + + this.onWillSaveRecord = params.onWillSaveRecord || (() => {}); + this.onRecordSaved = params.onRecordSaved || (() => {}); } async duplicateDatapoint(record, params) { @@ -1306,6 +1317,8 @@ export class RelationalModel extends Model { { __bm_load_params__: loadParams, mode: this.initialMode, + onWillSaveRecord: this.onWillSaveRecord, + onRecordSaved: this.onRecordSaved, }, state ); diff --git a/addons/web/static/src/views/form/form_controller.js b/addons/web/static/src/views/form/form_controller.js index 6fc65afc574..2085696fc34 100644 --- a/addons/web/static/src/views/form/form_controller.js +++ b/addons/web/static/src/views/form/form_controller.js @@ -131,6 +131,8 @@ export class FormController extends Component { mode, beforeLoadProm, component: this, + onRecordSaved: this.onRecordSaved.bind(this), + onWillSaveRecord: this.onWillSaveRecord.bind(this), }, { ignoreUseSampleModel: true, @@ -256,6 +258,22 @@ export class FormController extends Component { } } + /** + * onRecordSaved is a callBack that will be executed after the save + * if it was done. It will therefore not be executed if the record + * is invalid or if a server error is thrown. + * @param {Record} record + */ + async onRecordSaved(record) {} + + /** + * onWillSaveRecord is a callBack that will be executed before the + * record save if the record is valid if the record is valid. + * If it returns false, it will prevent the save. + * @param {Record} record + */ + async onWillSaveRecord(record) {} + displayName() { return this.model.root.data.display_name || this.env._t("New"); } diff --git a/addons/web/static/src/views/list/list_controller.js b/addons/web/static/src/views/list/list_controller.js index 8431f198222..8b287e0572c 100644 --- a/addons/web/static/src/views/list/list_controller.js +++ b/addons/web/static/src/views/list/list_controller.js @@ -73,6 +73,8 @@ export class ListController extends Component { groupsLimit: this.archInfo.groupsLimit, multiEdit: this.multiEdit, rootState, + onRecordSaved: this.onRecordSaved.bind(this), + onWillSaveRecord: this.onWillSaveRecord.bind(this), }); onWillStart(async () => { @@ -155,6 +157,22 @@ export class ListController extends Component { ); } + /** + * onRecordSaved is a callBack that will be executed after the save + * if it was done. It will therefore not be executed if the record + * is invalid or if a server error is thrown. + * @param {Record} record + */ + async onRecordSaved(record) {} + + /** + * onWillSaveRecord is a callBack that will be executed before the + * record save if the record is valid if the record is valid. + * If it returns false, it will prevent the save. + * @param {Record} record + */ + async onWillSaveRecord(record) {} + async createRecord({ group } = {}) { const list = (group && group.list) || this.model.root; if (this.editable) { diff --git a/addons/web/static/src/views/relational_model.js b/addons/web/static/src/views/relational_model.js index 60e3f90312a..87c8335791b 100644 --- a/addons/web/static/src/views/relational_model.js +++ b/addons/web/static/src/views/relational_model.js @@ -275,6 +275,9 @@ class DataPoint { this.fields = params.fields; this.setActiveFields(params.activeFields); + this.onWillSaveRecord = params.onWillSaveRecord || (() => {}); + this.onRecordSaved = params.onRecordSaved || (() => {}); + this.rawContext = params.rawContext; this.defaultContext = params.defaultContext; this.setup(params, state); @@ -1328,6 +1331,9 @@ export class Record extends DataPoint { }); return false; } + if ((await this.onWillSaveRecord(this)) === false) { + return false; + } const changes = this.getChanges(); const keys = Object.keys(changes); const hasChanges = this.isVirtual || keys.length; @@ -1374,6 +1380,7 @@ export class Record extends DataPoint { if (!options.stayInEdition) { this.switchMode("readonly"); } + await this.onRecordSaved(this); return true; } @@ -1459,7 +1466,7 @@ class DynamicList extends DataPoint { this.editedRecord = record; } if (params.onRecordWillSwitchMode) { - await params.onRecordWillSwitchMode(record, mode, options); + return await params.onRecordWillSwitchMode(record, mode, options); } }; } @@ -1807,6 +1814,22 @@ export class DynamicRecordList extends DynamicList { // Getters // ------------------------------------------------------------------------- + get commonRecordParams() { + return { + resModel: this.resModel, + fields: this.fields, + activeFields: this.activeFields, + onRecordSaved: this.onRecordSaved, + onRecordWillSwitchMode: this.onRecordWillSwitchMode, + onWillSaveRecord: this.onWillSaveRecord, + defaultContext: this.defaultContext, + rawContext: { + parent: this.rawContext, + make: () => this.context, + }, + }; + } + get quickCreateRecord() { return this.records.find((r) => r.isInQuickCreation); } @@ -1822,15 +1845,7 @@ export class DynamicRecordList extends DynamicList { */ async addExistingRecord(resId, atFirstPosition) { const newRecord = this.model.createDataPoint("record", { - resModel: this.resModel, - fields: this.fields, - activeFields: this.activeFields, - onRecordWillSwitchMode: this.onRecordWillSwitchMode, - defaultContext: this.defaultContext, - rawContext: { - parent: this.rawContext, - make: () => this.context, - }, + ...this.commonRecordParams, resId, }); if (this.model.useSampleModel) { @@ -1867,16 +1882,8 @@ export class DynamicRecordList extends DynamicList { */ async createRecord(params = {}, atFirstPosition = false) { const newRecord = this.model.createDataPoint("record", { - resModel: this.resModel, - fields: this.fields, - activeFields: this.activeFields, + ...this.commonRecordParams, parentActiveFields: this.activeFields, - onRecordWillSwitchMode: this.onRecordWillSwitchMode, - defaultContext: this.defaultContext, - rawContext: { - parent: this.rawContext, - make: () => this.context, - }, ...params, }); if (this.model.useSampleModel) { @@ -2051,12 +2058,10 @@ export class DynamicRecordList extends DynamicList { const records = await Promise.all( rawRecords.map(async (data) => { const record = this.model.createDataPoint("record", { - resModel: this.resModel, + ...this.commonRecordParams, resId: data.id, - fields: this.fields, - activeFields: this.activeFields, rawContext: this.rawContext, - onRecordWillSwitchMode: this.onRecordWillSwitchMode, + defaultContext: undefined, }); await record.load({ values: data }); return record; @@ -2130,7 +2135,9 @@ export class DynamicGroupList extends DynamicList { groupByInfo: this.groupByInfo, rawContext: this.rawContext, onCreateRecord: this.onCreateRecord, + onRecordSaved: this.onRecordSaved, onRecordWillSwitchMode: this.onRecordWillSwitchMode, + onWillSaveRecord: this.onWillSaveRecord, }; } @@ -2556,7 +2563,9 @@ export class Group extends DataPoint { countLimit: params.count, groupByInfo: params.groupByInfo, onCreateRecord: params.onCreateRecord, + onRecordSaved: this.onRecordSaved, onRecordWillSwitchMode: params.onRecordWillSwitchMode, + onWillSaveRecord: this.onWillSaveRecord, defaultContext: { ...params.defaultContext, [`default_${this.groupByField.name}`]: this.getServerValue(), @@ -2678,6 +2687,8 @@ export class Group extends DataPoint { resModel: this.resModel, resId: this.value, rawContext: this.rawContext, + onRecordSaved: this.onRecordSaved, + onWillSaveRecord: this.onWillSaveRecord, ...params, }); } @@ -3401,6 +3412,8 @@ export class RelationalModel extends Model { viewMode: params.viewMode || null, resModel: params.resModel, groupByInfo: params.groupByInfo, + onRecordSaved: params.onRecordSaved, + onWillSaveRecord: params.onWillSaveRecord, }; this.fieldNodes = params.fieldNodes; // used by the ListConfirmationDialog if (this.rootType === "record") { diff --git a/addons/web/static/tests/views/form/form_view_tests.js b/addons/web/static/tests/views/form/form_view_tests.js index f39693eabc3..bcd205e3249 100644 --- a/addons/web/static/tests/views/form/form_view_tests.js +++ b/addons/web/static/tests/views/form/form_view_tests.js @@ -1962,7 +1962,11 @@ QUnit.module("Views", (hooks) => { }); assert.containsOnce(target, "label.o_form_label", "Only one label should be displayed"); assert.strictEqual(target.querySelector("label.o_form_label").textContent, "label2"); - assert.containsOnce(target, ".o_inner_group > div", "This group should only contain one line"); + assert.containsOnce( + target, + ".o_inner_group > div", + "This group should only contain one line" + ); } ); @@ -12627,4 +12631,87 @@ QUnit.module("Views", (hooks) => { "Unable to save" ); }); + + QUnit.test( + "execute an action before and after each valid save in a form view", + async function (assert) { + const formView = registry.category("views").get("form"); + class CustomFormController extends formView.Controller { + async onRecordSaved(record) { + assert.step(`onRecordSaved ${record.resId}`); + } + + async onWillSaveRecord(record) { + assert.step(`onWillSaveRecord ${record.resId}`); + } + } + registry.category("views").add("custom_form", { + ...formView, + Controller: CustomFormController, + }); + + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: `
`, + mockRPC(route, args) { + if (args.method === "write") { + assert.step(`write ${args.args[0]}`); + } + }, + }); + + await editInput(target, "[name='foo'] input", ""); + await clickSave(target); + assert.verifySteps([]); + + await editInput(target, "[name='foo'] input", "YOLO"); + await clickSave(target); + assert.verifySteps(["onWillSaveRecord 1", "write 1", "onRecordSaved 1"]); + } + ); + + QUnit.test( + "don't exec a valid save with onWillSaveRecord in a form view", + async function (assert) { + const formView = registry.category("views").get("form"); + class CustomFormController extends formView.Controller { + async onRecordSaved(record) { + throw new Error("should not execute onRecordSaved"); + } + + async onWillSaveRecord(record) { + assert.step(`onWillSaveRecord ${record.resId}`); + return false; + } + } + registry.category("views").add("custom_form", { + ...formView, + Controller: CustomFormController, + }); + + await makeView({ + type: "form", + resModel: "partner", + resId: 1, + serverData, + arch: `
`, + mockRPC(route, args) { + if (args.method === "write") { + throw new Error("should not save the record"); + } + }, + }); + + await editInput(target, "[name='foo'] input", ""); + await clickSave(target); + assert.verifySteps([]); + + await editInput(target, "[name='foo'] input", "YOLO"); + await clickSave(target); + assert.verifySteps(["onWillSaveRecord 1"]); + } + ); }); diff --git a/addons/web/static/tests/views/list_view_tests.js b/addons/web/static/tests/views/list_view_tests.js index d0822476498..56b60cf52c6 100644 --- a/addons/web/static/tests/views/list_view_tests.js +++ b/addons/web/static/tests/views/list_view_tests.js @@ -1935,20 +1935,16 @@ QUnit.module("Views", (hooks) => { assert.expect(6); const listView = registry.category("views").get("list"); - class ListViewCustom extends listView.Controller { + class CustomListController extends listView.Controller { openRecord(record) { assert.step("openRecord"); assert.strictEqual(record.resId, 2); } } - registry.category("views").add( - "list", - { - ...listView, - Controller: ListViewCustom, - }, - { force: true } - ); + registry.category("views").add("custom_list", { + ...listView, + Controller: CustomListController, + }); serverData.models.foo.fields.foo.sortable = true; @@ -1956,7 +1952,7 @@ QUnit.module("Views", (hooks) => { type: "list", resModel: "foo", serverData, - arch: '', + arch: '', }); await click(target.querySelector("tr:nth-child(2) td:not(.o_list_record_selector)")); @@ -1971,6 +1967,142 @@ QUnit.module("Views", (hooks) => { assert.verifySteps(["openRecord", "openRecord"]); }); + QUnit.test( + "execute an action before and after each valid save in a list view", + async function (assert) { + const listView = registry.category("views").get("list"); + class CustomListController extends listView.Controller { + async onRecordSaved(record) { + assert.step(`onRecordSaved ${record.resId}`); + } + + async onWillSaveRecord(record) { + assert.step(`onWillSaveRecord ${record.resId}`); + } + } + registry.category("views").add( + "custom_list", + { + ...listView, + Controller: CustomListController, + }, + { force: true } + ); + + await makeView({ + type: "list", + resModel: "foo", + serverData, + arch: + '', + mockRPC: async (route, args) => { + if (args.method === "write") { + assert.step(`write ${args.args[0]}`); + } + }, + }); + + await click(target.querySelector(".o_data_cell")); + await editInput(target, "[name='foo'] input", ""); + await click(target, ".o_list_view"); + assert.verifySteps([]); + + await editInput(target, "[name='foo'] input", "YOLO"); + await click(target, ".o_list_view"); + assert.verifySteps(["onWillSaveRecord 1", "write 1", "onRecordSaved 1"]); + } + ); + + QUnit.test( + "execute an action before and after each valid save in a grouped list view", + async function (assert) { + const listView = registry.category("views").get("list"); + class CustomListController extends listView.Controller { + async onRecordSaved(record) { + assert.step(`onRecordSaved ${record.resId}`); + } + + async onWillSaveRecord(record) { + assert.step(`onWillSaveRecord ${record.resId}`); + } + } + registry.category("views").add("custom_list", { + ...listView, + Controller: CustomListController, + }); + + await makeView({ + type: "list", + resModel: "foo", + serverData, + arch: + '', + groupBy: ["bar"], + mockRPC: async (route, args) => { + if (args.method === "write") { + assert.step(`write ${args.args[0]}`); + } + }, + }); + + await click(target.querySelector(".o_data_cell[name='foo']")); + await editInput(target, "[name='foo'] input", ""); + await click(target, ".o_list_view"); + assert.verifySteps([]); + + await editInput(target, "[name='foo'] input", "YOLO"); + await click(target, ".o_list_view"); + assert.verifySteps(["onWillSaveRecord 4", "write 4", "onRecordSaved 4"]); + } + ); + + QUnit.test( + "don't exec a valid save with onWillSaveRecord in a list view", + async function (assert) { + const listView = registry.category("views").get("list"); + class ListViewCustom extends listView.Controller { + async onRecordSaved(record) { + throw new Error("should not execute onRecordSaved"); + } + + async onWillSaveRecord(record) { + assert.step(`onWillSaveRecord ${record.resId}`); + return false; + } + } + registry.category("views").add( + "list", + { + ...listView, + Controller: ListViewCustom, + }, + { force: true } + ); + + await makeView({ + type: "list", + resModel: "foo", + serverData, + arch: '', + mockRPC: async (route, args) => { + if (args.method === "write") { + throw new Error("should not save the record"); + } + }, + }); + + await click(target.querySelector(".o_data_cell")); + await editInput(target, "[name='foo'] input", ""); + await click(target, ".o_list_view"); + assert.verifySteps([]); + + await click(target.querySelector(".o_data_cell")); + await editInput(target, "[name='foo'] input", "YOLO"); + await click(target, ".o_list_view"); + assert.verifySteps(["onWillSaveRecord 1"]); + } + ); + QUnit.test("action/type attributes on tree arch, type='object'", async (assert) => { const list = await makeView({ type: "list",