From 370f53d8dcc88b4c6960fc321199df688d1d0810 Mon Sep 17 00:00:00 2001
From: Mohammed Shekha <msh@odoo.com>
Date: Thu, 19 Dec 2019 05:14:01 +0000
Subject: [PATCH] [IMP] web: conditional create/delete options on x2many fields

After this commit, x2many fields can have options like
create/delete which accept a domain, to make create/delete on
x2many conditional, say for example x2many field can have options
like:

options="{'create:' [('foo', '=', True)]', 'delete:' [('foo', '=', True)]'}"

With this when foo field is True, Create and Delete actions will
be available, but if foo is False then they won't.

In case of one2many fields, if 'create' is false, then 'Add a line'
(list) or 'Add' button (kanban) won't be displayed.

In case of many2many fields, 'Add a line' or 'Add' button will
always be displayed even if 'create' condition is false as it
doesn't really create records (but rather links existing ones).
Same applies for delete.

Task-2092953

closes odoo/odoo#42919

Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
Co-authored-by: Parth Chokshi <pch@odoo.com>
Co-authored-by: Aaron Bohy<aab@odoo.com>
---
 .../static/src/js/fields/relational_fields.js | 148 +++++++++---
 .../js/views/list/list_editable_renderer.js   |  68 +++---
 addons/web/static/src/xml/kanban.xml          |   2 +-
 .../field_many2many_tests.js                  | 215 ++++++++++++++++++
 .../relational_fields/field_one2many_tests.js |  99 ++++++++
 doc/reference/javascript_reference.rst        |  14 ++
 6 files changed, 486 insertions(+), 60 deletions(-)

diff --git a/addons/web/static/src/js/fields/relational_fields.js b/addons/web/static/src/js/fields/relational_fields.js
index deff2fbebcfe..500ebf1f609a 100644
--- a/addons/web/static/src/js/fields/relational_fields.js
+++ b/addons/web/static/src/js/fields/relational_fields.js
@@ -22,6 +22,7 @@ var data = require('web.data');
 var Dialog = require('web.Dialog');
 var dialogs = require('web.view_dialogs');
 var dom = require('web.dom');
+const Domain = require('web.Domain');
 var KanbanRecord = require('web.KanbanRecord');
 var KanbanRenderer = require('web.KanbanRenderer');
 var ListRenderer = require('web.ListRenderer');
@@ -117,21 +118,24 @@ var FieldMany2One = AbstractField.extend({
      * @override
      * @param {boolean} [options.noOpen=false] if true, there is no external
      *   button to open the related record in a dialog
+     * @param {boolean} [options.noCreate=false] if true, the many2one does not
+     *   allow to create records
      */
     init: function (parent, name, record, options) {
+        options = options || {};
         this._super.apply(this, arguments);
         this.limit = 7;
         this.orderer = new concurrency.DropMisordered();
 
-        // should normally also be set, except in standalone M20
-        this.can_create = ('can_create' in this.attrs ? JSON.parse(this.attrs.can_create) : true) &&
-            !this.nodeOptions.no_create;
+        // should normally be set, except in standalone M20
+        const canCreate = 'can_create' in this.attrs ? JSON.parse(this.attrs.can_create) : true;
+        this.can_create = canCreate && !this.nodeOptions.no_create && !options.noCreate;
         this.can_write = 'can_write' in this.attrs ? JSON.parse(this.attrs.can_write) : true;
 
         this.nodeOptions = _.defaults(this.nodeOptions, {
             quick_create: true,
         });
-        this.noOpen = 'noOpen' in (options || {}) ? options.noOpen : this.nodeOptions.no_open;
+        this.noOpen = 'noOpen' in options ? options.noOpen : this.nodeOptions.no_open;
         this.m2o_value = this._formatValue(this.value);
         // 'recordParams' is a dict of params used when calling functions
         // 'getDomain' and 'getContext' on this.record
@@ -591,10 +595,9 @@ var FieldMany2One = AbstractField.extend({
                 if (values.length > self.limit) {
                     values = self._manageSearchMore(values, search_val, domain, context);
                 }
-                var create_enabled = self.can_create && !self.nodeOptions.no_create;
                 // quick create
                 var raw_result = _.map(result, function (x) { return x[1]; });
-                if (create_enabled && !self.nodeOptions.no_quick_create &&
+                if (self.can_create && !self.nodeOptions.no_quick_create &&
                     search_val.length > 0 && !_.contains(raw_result, search_val)) {
                     values.push({
                         label: _.str.sprintf(_t('Create "<strong>%s</strong>"'),
@@ -604,7 +607,7 @@ var FieldMany2One = AbstractField.extend({
                     });
                 }
                 // create and edit ...
-                if (create_enabled && !self.nodeOptions.no_create_edit) {
+                if (self.can_create && !self.nodeOptions.no_create_edit) {
                     var createAndEditAction = function () {
                         // Clear the value in case the user clicks on discard
                         self.$('input').val('');
@@ -1012,6 +1015,7 @@ var FieldX2Many = AbstractField.extend({
                                             true;
             this.editable = arch.attrs.editable;
         }
+        this._computeAvailableActions(record);
         if (this.attrs.columnInvisibleFields) {
             this._processColumnInvisibleFields();
         }
@@ -1079,15 +1083,21 @@ var FieldX2Many = AbstractField.extend({
      * @returns {Promise}
      */
     reset: function (record, ev, fieldChanged) {
+        // re-evaluate available actions
+        const oldCanCreate = this.canCreate;
+        const oldCanDelete = this.canDelete;
+        this._computeAvailableActions(record);
+        const actionsChanged = this.canCreate !== oldCanCreate || this.canDelete !== oldCanDelete;
+
         // If 'fieldChanged' is false, it means that the reset was triggered by
-        // the 'resetOnAnyFieldChange' mechanism. If it is the case the
-        // modifiers are evaluated and if there is no change in the modifiers
-        // values, the reset is skipped.
-        if (!fieldChanged) {
-           var newEval = this._evalColumnInvisibleFields();
-           if (_.isEqual(this.currentColInvisibleFields, newEval)) {
-               return Promise.resolve();
-           }
+        // the 'resetOnAnyFieldChange' mechanism. If it is the case, if neither
+        // the modifiers (so the visible columns) nor the available actions
+        // changed, the reset is skipped.
+        if (!fieldChanged && !actionsChanged) {
+            var newEval = this._evalColumnInvisibleFields();
+            if (_.isEqual(this.currentColInvisibleFields, newEval)) {
+                return Promise.resolve();
+            }
         } else if (ev && ev.target === this && ev.data.changes && this.view.arch.tag === 'tree') {
             var command = ev.data.changes[this.name];
             // Here, we only consider 'UPDATE' commands with data, which occur
@@ -1104,7 +1114,9 @@ var FieldX2Many = AbstractField.extend({
                 return this.renderer.confirmUpdate(state, command.id, fieldNames, ev.initialEvent);
             }
         }
-        return this._super.apply(this, arguments);
+        return this._super.apply(this, arguments).then(() => {
+            this._updateControlPanel();
+        });
     },
 
     /**
@@ -1141,6 +1153,19 @@ var FieldX2Many = AbstractField.extend({
     // Private
     //--------------------------------------------------------------------------
 
+    /**
+     * @private
+     * @param {Object} record
+     */
+    _computeAvailableActions: function (record) {
+        const evalContext = record.evalContext;
+        this.canCreate = 'create' in this.nodeOptions ?
+            new Domain(this.nodeOptions.create, evalContext).compute(evalContext) :
+            true;
+        this.canDelete = 'delete' in this.nodeOptions ?
+            new Domain(this.nodeOptions.delete, evalContext).compute(evalContext) :
+            true;
+    },
     /**
      * Evaluates the 'column_invisible' modifier for the parent record.
      *
@@ -1155,6 +1180,18 @@ var FieldX2Many = AbstractField.extend({
              }).column_invisible;
         });
     },
+    /**
+     * Returns qweb context to render buttons.
+     *
+     * @private
+     * @returns {Object}
+     */
+    _getButtonsRenderingContext() {
+        return {
+            btnClass: 'btn-secondary',
+            create_text: this.nodeOptions.create_text,
+        };
+    },
     /**
      * Computes the default renderer to use depending on the view type.
      * We create this as a method so we can override it if we want to use
@@ -1171,6 +1208,20 @@ var FieldX2Many = AbstractField.extend({
             return KanbanRenderer;
         }
     },
+    /**
+     * @private
+     * @returns {boolean} true iff the list should contain a 'create' line.
+     */
+    _hasCreateLine: function () {
+        return !this.isReadonly && this.activeActions.create && (this.isMany2Many || this.canCreate);
+    },
+    /**
+     * @private
+     * @returns {boolean} true iff the list should add a trash icon on each row.
+     */
+    _hasTrashIcon: function () {
+        return !this.isReadonly && this.activeActions.delete && (this.isMany2Many || this.canDelete);
+    },
     /**
      * Instanciates or updates the adequate renderer.
      *
@@ -1183,9 +1234,12 @@ var FieldX2Many = AbstractField.extend({
         if (!this.view) {
             return this._super();
         }
+
         if (this.renderer) {
             this.currentColInvisibleFields = this._evalColumnInvisibleFields();
             return this.renderer.updateState(this.value, {
+                addCreateLine: this._hasCreateLine(),
+                addTrashIcon: this._hasTrashIcon(),
                 columnInvisibleFields: this.currentColInvisibleFields,
                 keepWidths: true,
             }).then(function () {
@@ -1203,8 +1257,8 @@ var FieldX2Many = AbstractField.extend({
             this.currentColInvisibleFields = this._evalColumnInvisibleFields();
             _.extend(rendererParams, {
                 editable: this.mode === 'edit' && arch.attrs.editable,
-                addCreateLine: !this.isReadonly && this.activeActions.create,
-                addTrashIcon: !this.isReadonly && this.activeActions.delete,
+                addCreateLine: this._hasCreateLine(),
+                addTrashIcon: this._hasTrashIcon(),
                 isMany2Many: this.isMany2Many,
                 columnInvisibleFields: this.currentColInvisibleFields,
             });
@@ -1301,10 +1355,8 @@ var FieldX2Many = AbstractField.extend({
      */
     _renderButtons: function () {
         if (!this.isReadonly && this.view.arch.tag === 'kanban') {
-            this.$buttons = $(qweb.render('KanbanView.buttons', {
-                btnClass: 'btn-secondary',
-                create_text: this.nodeOptions.create_text,
-            }));
+            const renderingContext = this._getButtonsRenderingContext();
+            this.$buttons = $(qweb.render('KanbanView.buttons', renderingContext));
             this.$buttons.on('click', 'button.o-kanban-button-new', this._onAddRecord.bind(this));
         }
     },
@@ -1347,6 +1399,23 @@ var FieldX2Many = AbstractField.extend({
             }
         });
     },
+    /**
+     * Re-renders buttons and updates the control panel. This method is called
+     * when the widget is reset, as the available buttons might have changed.
+     *
+     * @private
+     */
+    _updateControlPanel: function () {
+        if (this._controlPanel) {
+            this._renderButtons();
+            const params = {
+                cp_content: {
+                    $buttons: this.$buttons,
+                }
+            };
+            this._controlPanel.updateContents(params, { clear: false });
+        }
+    },
     /**
      * Parses the 'columnInvisibleFields' attribute to search for the domains
      * containing the key 'parent'. If there are such domains, the string
@@ -1729,6 +1798,15 @@ var FieldOne2Many = FieldX2Many.extend({
     // Private
     //--------------------------------------------------------------------------
 
+    /**
+     * @override
+     * @private
+     */
+    _getButtonsRenderingContext() {
+        const renderingContext = this._super(...arguments);
+        renderingContext.noCreate = !this.canCreate;
+        return renderingContext;
+    },
     /**
       * @override
       * @private
@@ -1769,7 +1847,7 @@ var FieldOne2Many = FieldX2Many.extend({
             fields_view: this.attrs.views && this.attrs.views.form,
             parentID: this.value.id,
             viewInfo: this.view,
-            deletable: this.activeActions.delete && params.deletable,
+            deletable: this.activeActions.delete && params.deletable && this.canDelete,
         }));
     },
 
@@ -1874,7 +1952,7 @@ var FieldOne2Many = FieldX2Many.extend({
             on_remove: function () {
                 self._setValue({operation: 'DELETE', ids: [id]});
             },
-            deletable: this.activeActions.delete && this.view.arch.tag !== 'tree',
+            deletable: this.activeActions.delete && this.view.arch.tag !== 'tree' && this.canDelete,
             readonly: this.mode === 'readonly',
         });
     },
@@ -1900,7 +1978,7 @@ var FieldMany2Many = FieldX2Many.extend({
             domain: domain.concat(["!", ["id", "in", this.value.res_ids]]),
             context: this.record.getContext(this.recordParams),
             title: _t("Add: ") + this.string,
-            no_create: this.nodeOptions.no_create || !this.activeActions.create,
+            no_create: this.nodeOptions.no_create || !this.activeActions.create || !this.canCreate,
             fields_view: this.attrs.views.form,
             kanban_view_ref: this.attrs.kanban_view_ref,
             on_selected: function (records) {
@@ -1960,7 +2038,7 @@ var FieldMany2Many = FieldX2Many.extend({
                 self._setValue({operation: 'FORGET', ids: [ev.data.id]});
             },
             readonly: this.mode === 'readonly',
-            deletable: this.activeActions.delete && this.view.arch.tag !== 'tree',
+            deletable: this.activeActions.delete && this.view.arch.tag !== 'tree' && this.canDelete,
             string: this.string,
         });
     },
@@ -2195,6 +2273,10 @@ var FieldMany2ManyTags = AbstractField.extend({
 
         this.colorField = this.nodeOptions.color_field;
         this.hasDropdown = false;
+
+        this._computeAvailableActions(this.record);
+        // have listen to react to other fields changes to re-evaluate 'create' option
+        this.resetOnAnyFieldChange = this.resetOnAnyFieldChange || 'create' in this.nodeOptions;
     },
 
     //--------------------------------------------------------------------------
@@ -2228,7 +2310,8 @@ var FieldMany2ManyTags = AbstractField.extend({
      */
     reset: function (record, event) {
         var self = this;
-        return this._super.apply(this, arguments).then(function(){
+        this._computeAvailableActions(record);
+        return this._super.apply(this, arguments).then(function () {
             if (event && event.target === self) {
                 self.activate();
             }
@@ -2251,6 +2334,16 @@ var FieldMany2ManyTags = AbstractField.extend({
             });
         }
     },
+    /**
+     * @private
+     * @param {Object} record
+     */
+    _computeAvailableActions: function (record) {
+        const evalContext = record.evalContext;
+        this.canCreate = 'create' in this.nodeOptions ?
+            new Domain(this.nodeOptions.create, evalContext).compute(evalContext) :
+            true;
+    },
     /**
      * Get the QWeb rendering context used by the tag template; this computation
      * is placed in a separate function for other tags to override it.
@@ -2290,6 +2383,7 @@ var FieldMany2ManyTags = AbstractField.extend({
         this.many2one = new FieldMany2One(this, this.name, this.record, {
             mode: 'edit',
             noOpen: true,
+            noCreate: !this.canCreate,
             viewType: this.viewType,
             attrs: this.attrs,
         });
diff --git a/addons/web/static/src/js/views/list/list_editable_renderer.js b/addons/web/static/src/js/views/list/list_editable_renderer.js
index 62f715d39675..53c63d3fbb90 100644
--- a/addons/web/static/src/js/views/list/list_editable_renderer.js
+++ b/addons/web/static/src/js/views/list/list_editable_renderer.js
@@ -42,7 +42,6 @@ ListRenderer.include({
      * @param {boolean} params.isMultiEditable
      */
     init: function (parent, state, params) {
-        var self = this;
         this._super.apply(this, arguments);
 
         this.editable = params.editable;
@@ -62,33 +61,27 @@ ListRenderer.include({
 
         // The following code will browse the arch to find
         // all the <create> that are inside <control>
-
-        if (this.addCreateLine) {
-            this.creates = [];
-
-            _.each(this.arch.children, function (child) {
-                if (child.tag !== 'control') {
+        this.creates = [];
+        this.arch.children.forEach(child => {
+            if (child.tag !== 'control') {
+                return;
+            }
+            child.children.forEach(child => {
+                if (child.tag !== 'create' || child.attrs.invisible) {
                     return;
                 }
-
-                _.each(child.children, function (child) {
-                    if (child.tag !== 'create' || child.attrs.invisible) {
-                        return;
-                    }
-
-                    self.creates.push({
-                        context: child.attrs.context,
-                        string: child.attrs.string,
-                    });
+                this.creates.push({
+                    context: child.attrs.context,
+                    string: child.attrs.string,
                 });
             });
+        });
 
-            // Add the default button if we didn't find any custom button.
-            if (this.creates.length === 0) {
-                this.creates.push({
-                    string: _t("Add a line"),
-                });
-            }
+        // Add the default button if we didn't find any custom button.
+        if (this.creates.length === 0) {
+            this.creates.push({
+                string: _t("Add a line"),
+            });
         }
 
         // if addTrashIcon is true, there will be a small trash icon at the end
@@ -532,6 +525,15 @@ ListRenderer.include({
             // them to be recomputed at next (sub-)rendering
             this.allModifiersData = [];
         }
+        if ('addTrashIcon' in params) {
+            if (this.addTrashIcon !== params.addTrashIcon) {
+                this.columnWidths = false; // columns changed, so forget stored widths
+            }
+            this.addTrashIcon = params.addTrashIcon;
+        }
+        if ('addCreateLine' in params) {
+            this.addCreateLine = params.addCreateLine;
+        }
         return this._super.apply(this, arguments);
     },
 
@@ -1200,15 +1202,17 @@ ListRenderer.include({
             $tr.append($td);
             $rows.push($tr);
 
-            _.each(this.creates, function (create, index) {
-                var $a = $('<a href="#" role="button">')
-                    .attr('data-context', create.context)
-                    .text(create.string);
-                if (index > 0) {
-                    $a.addClass('ml16');
-                }
-                $td.append($a);
-            });
+            if (this.addCreateLine) {
+                _.each(this.creates, function (create, index) {
+                    var $a = $('<a href="#" role="button">')
+                        .attr('data-context', create.context)
+                        .text(create.string);
+                    if (index > 0) {
+                        $a.addClass('ml16');
+                    }
+                    $td.append($a);
+                });
+            }
         }
         return $rows;
     },
diff --git a/addons/web/static/src/xml/kanban.xml b/addons/web/static/src/xml/kanban.xml
index 9d998677dc4d..1e7f36f8ef3f 100644
--- a/addons/web/static/src/xml/kanban.xml
+++ b/addons/web/static/src/xml/kanban.xml
@@ -2,7 +2,7 @@
 
 <t t-name="KanbanView.buttons">
     <div>
-        <button type="button" t-attf-class="btn #{btnClass} o-kanban-button-new" accesskey="c">
+        <button t-if="!noCreate" type="button" t-attf-class="btn #{btnClass} o-kanban-button-new" accesskey="c">
             <t t-esc="create_text || _t('Create')"/>
         </button>
     </div>
diff --git a/addons/web/static/tests/fields/relational_fields/field_many2many_tests.js b/addons/web/static/tests/fields/relational_fields/field_many2many_tests.js
index 487cd8647b83..7af0216e20e6 100644
--- a/addons/web/static/tests/fields/relational_fields/field_many2many_tests.js
+++ b/addons/web/static/tests/fields/relational_fields/field_many2many_tests.js
@@ -350,6 +350,74 @@ QUnit.module('fields', {}, function () {
             form.destroy();
         });
 
+        QUnit.test('many2many kanban: conditional create/delete actions', async function (assert) {
+            assert.expect(6);
+
+            this.data.partner.records[0].timmy = [12, 14];
+
+            const form = await createView({
+                View: FormView,
+                model: 'partner',
+                data: this.data,
+                arch: `
+                    <form>
+                        <field name="color"/>
+                        <field name="timmy" options="{'create': [('color', '=', 'red')], 'delete': [('color', '=', 'red')]}">
+                            <kanban>
+                                <field name="display_name"/>
+                                <templates>
+                                    <t t-name="kanban-box">
+                                        <div class="oe_kanban_global_click">
+                                            <span><t t-esc="record.display_name.value"/></span>
+                                        </div>
+                                    </t>
+                                </templates>
+                            </kanban>
+                        </field>
+                    </form>`,
+                archs: {
+                    'partner_type,false,form': '<form><field name="name"/></form>',
+                    'partner_type,false,list': '<tree><field name="name"/></tree>',
+                    'partner_type,false,search': '<search/>',
+                },
+                res_id: 1,
+                viewOptions: {
+                    mode: 'edit',
+                },
+            });
+
+            // color is red
+            assert.containsOnce(form, '.o-kanban-button-new', '"Add" button should be available');
+
+            await testUtils.dom.click(form.$('.o_kanban_record:contains(silver)'));
+            assert.containsOnce(document.body, '.modal .modal-footer .o_btn_remove',
+                'remove button should be visible in modal');
+            await testUtils.dom.click($('.modal .modal-footer .o_form_button_cancel'));
+
+            await testUtils.dom.click(form.$('.o-kanban-button-new'));
+            assert.containsN(document.body, '.modal .modal-footer button', 3,
+                'there should be 3 buttons available in the modal');
+            await testUtils.dom.click($('.modal .modal-footer .o_form_button_cancel'));
+
+            // set color to black
+            await testUtils.fields.editSelect(form.$('select[name="color"]'), '"black"');
+            assert.containsOnce(form, '.o-kanban-button-new',
+                '"Add" button should still be available even after color field changed');
+
+            await testUtils.dom.click(form.$('.o-kanban-button-new'));
+            // only select and cancel button should be available, create
+            // button should be removed based on color field condition
+            assert.containsN(document.body, '.modal .modal-footer button', 2,
+                '"Create" button should not be available in the modal after color field changed');
+            await testUtils.dom.click($('.modal .modal-footer .o_form_button_cancel'));
+
+            await testUtils.dom.click(form.$('.o_kanban_record:contains(silver)'));
+            assert.containsNone(document.body, '.modal .modal-footer .o_btn_remove',
+                'remove button should be visible in modal');
+
+            form.destroy();
+        });
+
         QUnit.test('many2many list (non editable): edition', async function (assert) {
             assert.expect(29);
 
@@ -642,6 +710,64 @@ QUnit.module('fields', {}, function () {
             form.destroy();
         });
 
+        QUnit.test('many2many list: conditional create/delete actions', async function (assert) {
+            assert.expect(6);
+
+            this.data.partner.records[0].timmy = [12, 14];
+
+            const form = await createView({
+                View: FormView,
+                model: 'partner',
+                data: this.data,
+                arch: `
+                    <form>
+                        <field name="color"/>
+                        <field name="timmy" options="{'create': [('color', '=', 'red')], 'delete': [('color', '=', 'red')]}">
+                            <tree>
+                                <field name="name"/>
+                            </tree>
+                        </field>
+                    </form>`,
+                archs: {
+                    'partner_type,false,list': '<tree><field name="name"/></tree>',
+                    'partner_type,false,search': '<search/>',
+                },
+                res_id: 1,
+                viewOptions: {
+                    mode: 'edit',
+                },
+            });
+
+            // color is red -> create and delete actions are available
+            assert.containsOnce(form, '.o_field_x2many_list_row_add',
+                "should have the 'Add an item' link");
+            assert.containsN(form, '.o_list_record_remove', 2,
+                "should have two remove icons");
+
+            await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+
+            assert.containsN(document.body, '.modal .modal-footer button', 3,
+                'there should be 3 buttons available in the modal');
+
+            await testUtils.dom.click($('.modal .modal-footer .o_form_button_cancel'));
+
+            // set color to black -> create and delete actions are no longer available
+            await testUtils.fields.editSelect(form.$('select[name="color"]'), '"black"');
+
+            // add a line and remove icon should still be there as they don't create/delete records,
+            // but rather add/remove links
+            assert.containsOnce(form, '.o_field_x2many_list_row_add',
+                '"Add a line" button should still be available even after color field changed');
+            assert.containsN(form, '.o_list_record_remove', 2,
+                "should still have remove icon even after color field changed");
+
+            await testUtils.dom.click(form.$('.o_field_x2many_list_row_add a'));
+            assert.containsN(document.body, '.modal .modal-footer button', 2,
+                '"Create" button should not be available in the modal after color field changed');
+
+            form.destroy();
+        });
+
         QUnit.test('many2many list: list of id as default value', async function (assert) {
             assert.expect(1);
 
@@ -1232,6 +1358,95 @@ QUnit.module('fields', {}, function () {
 
             form.destroy();
         });
+
+        QUnit.test('many2many_tags widget: conditional create/delete actions', async function (assert) {
+            assert.expect(10);
+
+            this.data.turtle.records[0].partner_ids = [2];
+            for (var i = 1; i <= 10; i++) {
+                this.data.partner.records.push({ id: 100 + i, display_name: "Partner" + i });
+            }
+
+            const form = await createView({
+                View: FormView,
+                model: 'turtle',
+                data: this.data,
+                arch: `
+                    <form>
+                        <field name="display_name"/>
+                        <field name="turtle_bar"/>
+                        <field name="partner_ids" options="{'create': [('turtle_bar', '=', True)], 'delete': [('turtle_bar', '=', True)]}" widget="many2many_tags"/>
+                    </form>`,
+                archs: {
+                    'partner,false,list': '<tree><field name="name"/></tree>',
+                    'partner,false,search': '<search/>',
+                },
+                res_id: 1,
+                viewOptions: {
+                    mode: 'edit',
+                },
+            });
+
+            // turtle_bar is true -> create and delete actions are available
+            assert.containsOnce(form, '.o_field_many2manytags.o_field_widget .badge .o_delete',
+                'X icon on badges should not be available');
+
+            await testUtils.fields.many2one.clickOpenDropdown('partner_ids');
+
+            const $dropdown1 = form.$('.o_field_many2one input').autocomplete('widget');
+            assert.containsOnce($dropdown1, 'li.o_m2o_dropdown_option:contains(Create and Edit...)',
+                'autocomplete should contain Create and Edit...');
+
+            await testUtils.fields.many2one.clickItem('partner_ids', 'Search More');
+
+            assert.containsN(document.body, '.modal .modal-footer button', 3,
+                'there should be 3 buttons (Select, Create and Cancel) available in the modal footer');
+
+            await testUtils.dom.click($('.modal .modal-footer .o_form_button_cancel'));
+
+            // type something that doesn't exist
+            await testUtils.fields.editAndTrigger(form.$('.o_field_many2one input'),
+                'Something that does not exist', 'keydown');
+            // await testUtils.nextTick();
+            assert.containsN($dropdown1, 'li.o_m2o_dropdown_option', 2,
+                'autocomplete should contain Create and Create and Edit... options');
+
+            // set turtle_bar false -> create and delete actions are no longer available
+            await testUtils.dom.click(form.$('.o_field_widget[name="turtle_bar"] input').first());
+
+            // remove icon should still be there as it doesn't delete records but rather remove links
+            assert.containsOnce(form, '.o_field_many2manytags.o_field_widget .badge .o_delete',
+                'X icon on badge should still be there even after turtle_bar is not checked');
+
+            await testUtils.fields.many2one.clickOpenDropdown('partner_ids');
+            const $dropdown2 = form.$('.o_field_many2one input').autocomplete('widget');
+
+            // only Search More option should be available
+            assert.containsOnce($dropdown2, 'li.o_m2o_dropdown_option',
+                'autocomplete should contain only one option');
+            assert.containsOnce($dropdown2, 'li.o_m2o_dropdown_option:contains(Search More)',
+                'autocomplete option should be Search More');
+
+            await testUtils.fields.many2one.clickItem('partner_ids', 'Search More');
+
+            assert.containsN(document.body, '.modal .modal-footer button', 2,
+                'there should be 2 buttons (Select and Cancel) available in the modal footer');
+
+            await testUtils.dom.click($('.modal .modal-footer .o_form_button_cancel'));
+
+            // type something that doesn't exist
+            await testUtils.fields.editAndTrigger(form.$('.o_field_many2one input'),
+                'Something that does not exist', 'keyup');
+            // await testUtils.nextTick();
+
+            // only Search More option should be available
+            assert.containsOnce($dropdown2, 'li.o_m2o_dropdown_option',
+                'autocomplete should contain only one option');
+            assert.containsOnce($dropdown2, 'li.o_m2o_dropdown_option:contains(Search More)',
+                'autocomplete option should be Search More');
+
+            form.destroy();
+        });
     });
 });
 });
diff --git a/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js b/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js
index 8aacf099de4c..ad68ede00d22 100644
--- a/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js
+++ b/addons/web/static/tests/fields/relational_fields/field_one2many_tests.js
@@ -2142,6 +2142,46 @@ QUnit.module('fields', {}, function () {
             form.destroy();
         });
 
+        QUnit.test('one2many list: conditional create/delete actions', async function (assert) {
+            assert.expect(4);
+
+            this.data.partner.records[0].p = [2, 4];
+            const form = await createView({
+                View: FormView,
+                model: 'partner',
+                data: this.data,
+                arch: `
+                    <form>
+                        <field name="bar"/>
+                        <field name="p" options="{'create': [('bar', '=', True)], 'delete': [('bar', '=', True)]}">
+                            <tree>
+                                <field name="display_name"/>
+                            </tree>
+                        </field>
+                    </form>`,
+                res_id: 1,
+                viewOptions: {
+                    mode: 'edit',
+                },
+            });
+
+            // bar is true -> create and delete action are available
+            assert.containsOnce(form, '.o_field_x2many_list_row_add',
+                '"Add an item" link should be available');
+            assert.hasClass(form.$('td.o_list_record_remove button').first(), 'fa fa-trash-o',
+                "should have trash bin icons");
+
+            // set bar to false -> create and delete action are no longer available
+            await testUtils.dom.click(form.$('.o_field_widget[name="bar"] input').first());
+
+            assert.containsNone(form, '.o_field_x2many_list_row_add',
+                '"Add an item" link should not be available if bar field is False');
+            assert.containsNone(form, 'td.o_list_record_remove button',
+                "should not have trash bin icons if bar field is False");
+
+            form.destroy();
+        });
+
         QUnit.test('one2many list: unlink two records', async function (assert) {
             assert.expect(8);
             this.data.partner.records[0].p = [1, 2, 4];
@@ -2441,6 +2481,65 @@ QUnit.module('fields', {}, function () {
             form.destroy();
         });
 
+        QUnit.test('one2many kanban: conditional create/delete actions', async function (assert) {
+            assert.expect(4);
+
+            this.data.partner.records[0].p = [2, 4];
+
+            const form = await createView({
+                View: FormView,
+                model: 'partner',
+                data: this.data,
+                arch: `
+                    <form>
+                        <field name="bar"/>
+                        <field name="p" options="{'create': [('bar', '=', True)], 'delete': [('bar', '=', True)]}">
+                            <kanban>
+                                <field name="display_name"/>
+                                <templates>
+                                    <t t-name="kanban-box">
+                                        <div class="oe_kanban_global_click">
+                                            <span><t t-esc="record.display_name.value"/></span>
+                                        </div>
+                                    </t>
+                                </templates>
+                            </kanban>
+                            <form>
+                                <field name="display_name"/>
+                                <field name="foo"/>
+                            </form>
+                        </field>
+                    </form>`,
+                res_id: 1,
+                viewOptions: {
+                    mode: 'edit',
+                },
+            });
+
+            // bar is initially true -> create and delete actions are available
+            assert.containsOnce(form, '.o-kanban-button-new', '"Add" button should be available');
+
+            await testUtils.dom.click(form.$('.oe_kanban_global_click').first());
+
+            assert.containsOnce(document.body, '.modal .modal-footer .o_btn_remove',
+                'There should be a Remove Button inside modal');
+
+            await testUtils.dom.click($('.modal .modal-footer .o_form_button_cancel'));
+
+            // set bar false -> create and delete actions are no longer available
+            await testUtils.dom.click(form.$('.o_field_widget[name="bar"] input').first());
+
+            assert.containsNone(form, '.o-kanban-button-new',
+                '"Add" button should not be available as bar is False');
+
+            await testUtils.dom.click(form.$('.oe_kanban_global_click').first());
+
+            assert.containsNone(document.body, '.modal .modal-footer .o_btn_remove',
+                'There should not be a Remove Button as bar field is False');
+
+            form.destroy();
+        });
+
         QUnit.test('editable one2many list, pager is updated', async function (assert) {
             assert.expect(1);
 
diff --git a/doc/reference/javascript_reference.rst b/doc/reference/javascript_reference.rst
index 912c4db25da0..f0db5cd40b2c 100644
--- a/doc/reference/javascript_reference.rst
+++ b/doc/reference/javascript_reference.rst
@@ -2137,6 +2137,12 @@ Relational fields
 
     Options:
 
+    - create: domain determining whether or not new tags can be created (default: True).
+
+    .. code-block:: xml
+
+        <field name="category_id" widget="many2many_tags" options="{'create': [['some_other_field', '>', 24]]}"/>
+
     - color_field: the name of a numeric field, which should be present in the
       view.  A color will be chosen depending on its value.
 
@@ -2176,6 +2182,14 @@ Relational fields
 
     Options:
 
+    - create: domain determining whether or not related records can be created (default: True).
+
+    - delete: domain determining whether or not related records can be deleted (default: True).
+
+    .. code-block:: xml
+
+        <field name="turtles" options="{'create': [['some_other_field', '>', 24]]}"/>
+
     - create_text: a string that is used to customize the 'Add' label/text.
 
     .. code-block:: xml
-- 
GitLab