diff --git a/addons/loyalty/__manifest__.py b/addons/loyalty/__manifest__.py
index 6026311060b..11dd0d03309 100644
--- a/addons/loyalty/__manifest__.py
+++ b/addons/loyalty/__manifest__.py
@@ -28,10 +28,14 @@
'loyalty/static/src/js/loyalty_control_panel_widget.js',
'loyalty/static/src/js/loyalty_list_view.js',
'loyalty/static/src/scss/loyalty.scss',
+ 'loyalty/static/src/js/filterable_selection_field/filterable_selection_field.js',
],
'web.assets_qweb': [
'loyalty/static/src/xml/loyalty_templates.xml',
],
+ 'web.qunit_suite_tests': [
+ 'loyalty/static/tests/**/*.js',
+ ],
},
'installable': True,
'license': 'LGPL-3',
diff --git a/addons/loyalty/data/loyalty_demo.xml b/addons/loyalty/data/loyalty_demo.xml
index 76f0338ecde..e463796f3a8 100644
--- a/addons/loyalty/data/loyalty_demo.xml
+++ b/addons/loyalty/data/loyalty_demo.xml
@@ -3,8 +3,10 @@
Code for 10% on orders
- promotion
+ promo_code
with_code
+ False
+ Discount point(s)
@@ -20,13 +22,14 @@
order
-
-
Buy 3 large cabinets, get one for free
- promotion
+ buy_x_get_y
+ current
auto
+ False
+ Credit(s)
@@ -35,7 +38,7 @@
1
-
+
product
@@ -43,12 +46,13 @@
3
-
- 10% Discount
- future
+ 10% Discount Coupons
+ coupons
+ current
with_code
+ Coupon points
@@ -68,13 +72,14 @@
-
Gift Cards
gift_card
future
auto
+ True
+ $
@@ -93,5 +98,4 @@
1
-
diff --git a/addons/loyalty/models/loyalty_card.py b/addons/loyalty/models/loyalty_card.py
index d387caeee2d..59c30e80af5 100644
--- a/addons/loyalty/models/loyalty_card.py
+++ b/addons/loyalty/models/loyalty_card.py
@@ -17,18 +17,19 @@ class LoyaltyCard(models.Model):
"""
Barcode identifiable codes.
"""
- return '044' + str(uuid4())[4:-8][3:]
+ return '044' + str(uuid4())[7:-18]
def name_get(self):
return [(card.id, f'{card.program_id.name}: {card.code}') for card in self]
- program_id = fields.Many2one('loyalty.program', ondelete='restrict')
+ program_id = fields.Many2one('loyalty.program', ondelete='restrict', default=lambda self: self.env.context.get('active_id', None))
company_id = fields.Many2one(related='program_id.company_id', store=True)
currency_id = fields.Many2one(related='program_id.currency_id')
# Reserved for this partner if non-empty
partner_id = fields.Many2one('res.partner', index=True)
points = fields.Float(tracking=True)
point_name = fields.Char(related='program_id.portal_point_name', readonly=True)
+ points_display = fields.Char(compute='_compute_points_display')
code = fields.Char(default=lambda self: self._generate_code(), required=True, readonly=True, index=True)
expiration_date = fields.Date()
@@ -45,6 +46,11 @@ class LoyaltyCard(models.Model):
if self.env['loyalty.rule'].search_count([('mode', '=', 'with_code'), ('code', 'in', self.mapped('code'))]):
raise ValidationError(_('A trigger with the same code as one of your coupon already exists.'))
+ @api.depends('points', 'point_name')
+ def _compute_points_display(self):
+ for card in self:
+ card.points_display = "%.2f %s" % (card.points or 0, card.point_name or '')
+
# Meant to be overriden
def _compute_use_count(self):
self.use_count = 0
diff --git a/addons/loyalty/models/loyalty_program.py b/addons/loyalty/models/loyalty_program.py
index a9291901367..3c557394659 100644
--- a/addons/loyalty/models/loyalty_program.py
+++ b/addons/loyalty/models/loyalty_program.py
@@ -24,21 +24,31 @@ class LoyaltyProgram(models.Model):
total_order_count = fields.Integer("Total Order Count", compute="_compute_total_order_count")
- rule_ids = fields.One2many('loyalty.rule', 'program_id', 'Triggers', copy=True,
+ rule_ids = fields.One2many('loyalty.rule', 'program_id', 'Conditional rules', copy=True,
compute='_compute_from_program_type', readonly=False, store=True)
reward_ids = fields.One2many('loyalty.reward', 'program_id', 'Rewards', copy=True,
compute='_compute_from_program_type', readonly=False, store=True)
communication_plan_ids = fields.One2many('loyalty.mail', 'program_id', copy=True,
compute='_compute_from_program_type', readonly=False, store=True)
+
+ # These fields are used for the simplified view of gift_card and ewallet
+ mail_template_id = fields.Many2one('mail.template', compute='_compute_mail_template_id', inverse='_inverse_mail_template_id', string="Email template", readonly=False)
+ trigger_product_ids = fields.Many2many(related='rule_ids.product_ids', readonly=False)
+
coupon_ids = fields.One2many('loyalty.card', 'program_id')
coupon_count = fields.Integer(compute='_compute_coupon_count')
+ coupon_count_display = fields.Char(compute='_compute_coupon_count_display', string="Items")
program_type = fields.Selection([
('coupons', 'Coupons'),
('gift_card', 'Gift Card'),
('loyalty', 'Loyalty Cards'),
('promotion', 'Promotions'),
- ('ewallet', 'eWallet')], default='promotion', required=True,
+ ('ewallet', 'eWallet'),
+ ('promo_code', 'Discount Code'),
+ ('buy_x_get_y', 'Buy X Get Y'),
+ ('next_order_coupons', 'Next Order Coupons')],
+ default='promotion', required=True,
)
date_to = fields.Date(string='Validity')
limit_usage = fields.Boolean(string='Limit Usage')
@@ -67,9 +77,16 @@ class LoyaltyProgram(models.Model):
Show in web portal, PoS customer ticket, eCommerce checkout, the number of points available and used by reward.
""")
portal_point_name = fields.Char(default='Points', translate=True,
- compute='_compute_from_program_type', readonly=False, store=True)
+ compute='_compute_portal_point_name', readonly=False, store=True)
is_nominative = fields.Boolean(compute='_compute_is_nominative')
+ # Technical field used for a label
+ available_on = fields.Boolean("Available On", store=False,
+ help="""
+ Manage where your program should be available for use.
+ """
+ )
+
_sql_constraints = [
('check_max_usage', 'CHECK (limit_usage = False OR max_usage > 0)',
'Max usage must be strictly positive if a limit is used.'),
@@ -77,12 +94,43 @@ class LoyaltyProgram(models.Model):
@api.constrains('reward_ids')
def _constrains_reward_ids(self):
+ if self.env.context.get('loyalty_skip_reward_check'):
+ return
if any(not program.reward_ids for program in self):
raise ValidationError(_('A program must have at least one reward.'))
def _compute_total_order_count(self):
self.total_order_count = 0
+ @api.depends('coupon_count', 'program_type')
+ def _compute_coupon_count_display(self):
+ program_items_name = self._program_items_name()
+ for program in self:
+ program.coupon_count_display = "%i %s" % (program.coupon_count or 0, program_items_name[program.program_type] or '')
+
+ @api.depends("communication_plan_ids.mail_template_id")
+ def _compute_mail_template_id(self):
+ for program in self:
+ program.mail_template_id = program.communication_plan_ids.mail_template_id[:1]
+
+ def _inverse_mail_template_id(self):
+ for program in self:
+ if program.program_type not in ("gift_card", "ewallet"):
+ continue
+ if not program.mail_template_id:
+ program.communication_plan_ids = [(5, 0, 0)]
+ elif not program.communication_plan_ids:
+ program.communication_plan_ids = self.env['loyalty.mail'].create({
+ 'program_id': program.id,
+ 'trigger': 'create',
+ 'mail_template_id': program.mail_template_id.id,
+ })
+ else:
+ program.communication_plan_ids.write({
+ 'trigger': 'create',
+ 'mail_template_id': program.mail_template_id.id,
+ })
+
@api.depends('company_id')
def _compute_currency_id(self):
for program in self:
@@ -101,17 +149,30 @@ class LoyaltyProgram(models.Model):
program.is_nominative = program.applies_on == 'both' or\
(program.program_type == 'ewallet' and program.applies_on == 'future')
+ @api.model
+ def _program_items_name(self):
+ return {
+ 'coupons': _('Coupons'),
+ 'promotion': _('Promos'),
+ 'gift_card': _('Gift Cards'),
+ 'loyalty': _('Loyalty Cards'),
+ 'ewallet': _('eWallets'),
+ 'promo_code': _('Discounts'),
+ 'buy_x_get_y': _('Promos'),
+ 'next_order_coupons': _('Coupons'),
+ }
+
@api.model
def _program_type_default_values(self):
# All values to change when program_type changes
# NOTE: any field used in `rule_ids`, `reward_ids` and `communication_plan_ids` MUST be present in the kanban view for it to work properly.
+ first_sale_product = self.env['product.product'].search([('sale_ok', '=', True)], limit=1)
return {
'coupons': {
'applies_on': 'current',
'trigger': 'with_code',
'portal_visible': False,
- 'portal_point_name': _('Points'),
- # Coupons don't use rules by default
+ 'portal_point_name': _('Coupon point(s)'),
'rule_ids': [(5, 0, 0)],
'reward_ids': [(5, 0, 0), (0, 0, {
'required_points': 1,
@@ -126,7 +187,7 @@ class LoyaltyProgram(models.Model):
'applies_on': 'current',
'trigger': 'auto',
'portal_visible': False,
- 'portal_point_name': _('Points'),
+ 'portal_point_name': _('Promo point(s)'),
'rule_ids': [(5, 0, 0), (0, 0, {
'reward_point_amount': 1,
'reward_point_mode': 'order',
@@ -155,7 +216,7 @@ class LoyaltyProgram(models.Model):
'discount': 1,
'discount_applicability': 'order',
'required_points': 1,
- 'description': _('Pay With Gift Card'),
+ 'description': _('Gift Card'),
})],
'communication_plan_ids': [(5, 0, 0), (0, 0, {
'trigger': 'create',
@@ -166,7 +227,7 @@ class LoyaltyProgram(models.Model):
'applies_on': 'both',
'trigger': 'auto',
'portal_visible': True,
- 'portal_point_name': _('Loyalty Points'),
+ 'portal_point_name': _('Loyalty point(s)'),
'rule_ids': [(5, 0, 0), (0, 0, {
'reward_point_mode': 'money',
})],
@@ -177,8 +238,8 @@ class LoyaltyProgram(models.Model):
'communication_plan_ids': [(5, 0, 0)],
},
'ewallet': {
- 'applies_on': 'future',
'trigger': 'auto',
+ 'applies_on': 'future',
'portal_visible': True,
'portal_point_name': self.env.company.currency_id.symbol,
'rule_ids': [(5, 0, 0), (0, 0, {
@@ -192,9 +253,62 @@ class LoyaltyProgram(models.Model):
'discount': 1,
'discount_applicability': 'order',
'required_points': 1,
+ 'description': _('eWallet'),
})],
'communication_plan_ids': [(5, 0, 0)],
},
+ 'promo_code': {
+ 'applies_on': 'current',
+ 'trigger': 'with_code',
+ 'portal_visible': False,
+ 'portal_point_name': _('Discount point(s)'),
+ 'rule_ids': [(5, 0, 0), (0, 0, {
+ 'mode': 'with_code',
+ 'code': 'PROMO_CODE_' + str(uuid4())[:4], # We should try not to trigger any unicity constraint
+ })],
+ 'reward_ids': [(5, 0, 0), (0, 0, {
+ 'discount_applicability': 'specific',
+ 'discount_product_ids': first_sale_product,
+ 'discount_mode': 'percent',
+ 'discount': 10,
+ })],
+ 'communication_plan_ids': [(5, 0, 0)],
+ },
+ 'buy_x_get_y': {
+ 'applies_on': 'current',
+ 'trigger': 'auto',
+ 'portal_visible': False,
+ 'portal_point_name': _('Credit(s)'),
+ 'rule_ids': [(5, 0, 0), (0, 0, {
+ 'reward_point_mode': 'unit',
+ 'product_ids': first_sale_product,
+ })],
+ 'reward_ids': [(5, 0, 0), (0, 0, {
+ 'reward_type': 'product',
+ 'reward_product_id': first_sale_product.id,
+ 'required_points': 2,
+ })],
+ 'communication_plan_ids': [(5, 0, 0)],
+ },
+ 'next_order_coupons': {
+ 'applies_on': 'future',
+ 'trigger': 'auto',
+ 'portal_visible': True,
+ 'portal_point_name': _('Coupon point(s)'),
+ 'rule_ids': [(5, 0, 0), (0, 0, {
+ 'minimum_amount': 100,
+ })],
+ 'reward_ids': [(5, 0, 0), (0, 0, {
+ 'reward_type': 'discount',
+ 'discount_mode': 'percent',
+ 'discount': 15,
+ 'discount_applicability': 'order',
+ })],
+ 'communication_plan_ids': [(5, 0, 0), (0, 0, {
+ 'trigger': 'create',
+ 'mail_template_id': (self.env.ref('loyalty.mail_template_gift_card', raise_if_not_found=False) or self.env['mail.template']).id,
+ })],
+ },
}
@api.depends('program_type')
@@ -207,6 +321,13 @@ class LoyaltyProgram(models.Model):
if program_type in program_type_defaults:
programs.write(program_type_defaults[program_type])
+ @api.depends("currency_id", "program_type")
+ def _compute_portal_point_name(self):
+ for program in self:
+ if program.program_type not in ('ewallet', 'gift_card'):
+ continue
+ program.portal_point_name = program.currency_id.symbol or ''
+
def _get_valid_products(self, products):
'''
Returns a dict containing the products that match per rule of the program
@@ -220,6 +341,13 @@ class LoyaltyProgram(models.Model):
rule_products[rule] = products
return rule_products
+ def action_open_loyalty_cards(self):
+ self.ensure_one()
+ action = self.env['ir.actions.act_window']._for_xml_id("loyalty.loyalty_card_action")
+ action['name'] = self._program_items_name()[self.program_type]
+ action['display_name'] = action['name']
+ return action
+
@api.ondelete(at_uninstall=False)
def _unlink_except_active(self):
if any(program.active for program in self):
@@ -234,22 +362,39 @@ class LoyaltyProgram(models.Model):
program.communication_plan_ids.active = program.active
program.reward_ids.discount_line_product_id.active = program.active
+ def write(self, vals):
+ # There is an issue when we change the program type, since we clear the rewards and create new ones.
+ # The orm actually does it in this order upon writing, triggering the constraint before creating the new rewards.
+ # However we can check that the result of reward_ids would actually be empty or not, and if not, skip the constraint.
+ if 'reward_ids' in vals and self._fields['reward_ids'].convert_to_cache(vals['reward_ids'], self):
+ self = self.with_context(loyalty_skip_reward_check=True)
+ return super().write(vals)
+
@api.model
def get_program_templates(self):
'''
Returns the templates to be used for promotional programs.
'''
+ ctx_menu_type = self.env.context.get('menu_type')
+ if ctx_menu_type == 'gift_ewallet':
+ return {
+ 'gift_card': {
+ 'title': _('Gift Card'),
+ 'description': _('Sell Gift Cards, that can be used to purchase products'),
+ 'icon': 'gift_card',
+ },
+ 'ewallet': {
+ 'title': _('eWallet'),
+ 'description': _('Fill in your eWallet, and use it to pay future orders'),
+ 'icon': 'ewallet',
+ },
+ }
return {
'promo_code': {
'title': _('Promo Code'),
'description': _('Get a code to receive 10% discount on specific products'),
'icon': 'promo_code',
},
- 'gift_card': {
- 'title': _('Gift Card'),
- 'description': _('Sell Gift Cards, that can be used to purchase products'),
- 'icon': 'gift_card',
- },
'loyalty': {
'title': _('Loyalty Cards'),
'description': _('Win points with each purchases, and use points to get gifts'),
@@ -270,15 +415,15 @@ class LoyaltyProgram(models.Model):
'description': _('Send unique coupons that give access to rewards'),
'icon': 'coupons',
},
- 'buy_two_get_one': {
+ 'buy_x_get_y': {
'title': _('2+1 Free'),
'description': _('Buy 2 products and get a third one for free'),
'icon': '2_plus_1',
},
- 'ewallet': {
- 'title': _('eWallet'),
- 'description': _('Fill in your eWallet, and use it to pay future orders'),
- 'icon': 'ewallet',
+ 'next_order_coupons': {
+ 'title': _('Next Order Coupons'),
+ 'description': _('Send unique, single-use coupon code for the next purchase'),
+ 'icon': 'coupons',
},
}
@@ -293,7 +438,11 @@ class LoyaltyProgram(models.Model):
if template_id not in template_values:
return False
program = self.create(template_values[template_id])
- action = self.env['ir.actions.act_window']._for_xml_id('loyalty.loyalty_program_action')
+ action = {}
+ if self.env.context.get('menu_type') == 'gift_ewallet':
+ action = self.env['ir.actions.act_window']._for_xml_id('loyalty.loyalty_program_discount_loyalty_action')
+ else:
+ action = self.env['ir.actions.act_window']._for_xml_id('loyalty.loyalty_program_gift_ewallet_action')
action['views'] = [[False, 'form']]
action['view_mode'] = 'form'
action['res_id'] = program.id
@@ -334,20 +483,19 @@ class LoyaltyProgram(models.Model):
**program_type_defaults['promotion'],
},
'promo_code': {
- 'name': _('Promo Code'),
- 'program_type': 'promotion',
- 'applies_on': 'current',
- 'trigger': 'with_code',
- 'rule_ids': [(0, 0, {
- 'mode': 'with_code',
- 'code': '10PERCENT_' + str(uuid4())[:4], # Require a random code in case a user creates multiple program using this template
- })],
- 'reward_ids': [(0, 0, {
- 'discount_applicability': 'specific',
- 'discount_product_ids': product,
- 'discount_mode': 'percent',
- 'discount': 10,
- })]
+ 'name': _('Discount code'),
+ 'program_type': 'promo_code',
+ **program_type_defaults['promo_code'],
+ },
+ 'buy_x_get_y': {
+ 'name': _('2+1 Free'),
+ 'program_type': 'buy_x_get_y',
+ **program_type_defaults['buy_x_get_y'],
+ },
+ 'next_order_coupons': {
+ 'name': _('Next Order Coupons'),
+ 'program_type': 'next_order_coupons',
+ **program_type_defaults['next_order_coupons'],
},
'fidelity': {
'name': _('Fidelity Cards'),
@@ -366,19 +514,4 @@ class LoyaltyProgram(models.Model):
'discount': 10,
})]
},
- 'buy_two_get_one': {
- 'name': _('2+1 Free'),
- 'program_type': 'promotion',
- 'applies_on': 'current',
- 'trigger': 'auto',
- 'rule_ids': [(0, 0, {
- 'reward_point_mode': 'unit',
- 'product_ids': product,
- })],
- 'reward_ids': [(0, 0, {
- 'reward_type': 'product',
- 'reward_product_id': product and product.id or False,
- 'required_points': 2,
- })]
- },
}
diff --git a/addons/loyalty/models/loyalty_reward.py b/addons/loyalty/models/loyalty_reward.py
index 6ee6785bd5b..24bfc8fee38 100644
--- a/addons/loyalty/models/loyalty_reward.py
+++ b/addons/loyalty/models/loyalty_reward.py
@@ -27,6 +27,7 @@ class LoyaltyReward(models.Model):
active = fields.Boolean(default=True)
program_id = fields.Many2one('loyalty.program', required=True, ondelete='cascade')
+ program_type = fields.Selection(related="program_id.program_type")
# Stored for security rules
company_id = fields.Many2one(related='program_id.company_id', store=True)
currency_id = fields.Many2one(related='program_id.currency_id')
@@ -38,6 +39,7 @@ class LoyaltyReward(models.Model):
('discount', 'Discount')],
default='discount', required=True,
)
+ user_has_debug = fields.Boolean(compute='_compute_user_has_debug')
# Discount rewards
discount = fields.Float('Discount', default=10)
@@ -116,9 +118,15 @@ class LoyaltyReward(models.Model):
def _compute_description(self):
for reward in self:
reward_string = ""
- if reward.reward_type == 'product':
+ if reward.program_type == 'gift_card':
+ reward_string = _("Gift Card")
+ elif reward.program_type == 'ewallet':
+ reward_string = _("eWallet")
+ elif reward.reward_type == 'product':
products = reward.reward_product_ids
- if len(products) == 1:
+ if len(products) == 0:
+ reward_string = _('Free Product')
+ elif len(products) == 1:
reward_string = _('Free Product - %s', reward.reward_product_id.name)
else:
reward_string = _('Free Product - [%s]', ', '.join(products.mapped('name')))
@@ -157,6 +165,11 @@ class LoyaltyReward(models.Model):
reward.discount_applicability == 'order' and\
reward.discount_mode == 'percent'
+ @api.depends_context('uid')
+ @api.depends("reward_type")
+ def _compute_user_has_debug(self):
+ self.user_has_debug = self.user_has_groups('base.group_no_one')
+
def _create_missing_discount_line_products(self):
# Make sure we create the product that will be used for our discounts
rewards = self.filtered(lambda r: not r.discount_line_product_id)
diff --git a/addons/loyalty/models/loyalty_rule.py b/addons/loyalty/models/loyalty_rule.py
index ecab0d8d222..29bba2d7182 100644
--- a/addons/loyalty/models/loyalty_rule.py
+++ b/addons/loyalty/models/loyalty_rule.py
@@ -23,11 +23,13 @@ class LoyaltyRule(models.Model):
active = fields.Boolean(default=True)
program_id = fields.Many2one('loyalty.program', required=True, ondelete='cascade')
+ program_type = fields.Selection(related="program_id.program_type")
# Stored for security rules
company_id = fields.Many2one(related='program_id.company_id', store=True)
currency_id = fields.Many2one(related='program_id.currency_id')
# Only for dev mode
+ user_has_debug = fields.Boolean(compute='_compute_user_has_debug')
product_domain = fields.Char(default="[]")
product_ids = fields.Many2many('product.product', string='Products')
@@ -51,8 +53,8 @@ class LoyaltyRule(models.Model):
mode = fields.Selection([
('auto', 'Automatic'),
('with_code', 'With a promotion code'),
- ], string="Application", default="auto")
- code = fields.Char(string='Promotion Code', compute='_compute_code', store=True, readonly=False)
+ ], string="Application", compute='_compute_mode', store=True, readonly=False)
+ code = fields.Char(string='Discount code', compute='_compute_code', store=True, readonly=False)
_sql_constraints = [
('reward_point_amount_positive', 'CHECK (reward_point_amount > 0)', 'Rule points reward must be strictly positive.'),
@@ -84,6 +86,19 @@ class LoyaltyRule(models.Model):
if rule.mode == 'auto':
rule.code = False
+ @api.depends('code')
+ def _compute_mode(self):
+ for rule in self:
+ if rule.code:
+ rule.mode = 'with_code'
+ else:
+ rule.mode = 'auto'
+
+ @api.depends_context('uid')
+ @api.depends("mode")
+ def _compute_user_has_debug(self):
+ self.user_has_debug = self.user_has_groups('base.group_no_one')
+
def _get_valid_product_domain(self):
self.ensure_one()
domain = []
diff --git a/addons/loyalty/static/src/js/filterable_selection_field/filterable_selection_field.js b/addons/loyalty/static/src/js/filterable_selection_field/filterable_selection_field.js
new file mode 100644
index 00000000000..c925e5aec8d
--- /dev/null
+++ b/addons/loyalty/static/src/js/filterable_selection_field/filterable_selection_field.js
@@ -0,0 +1,44 @@
+/** @odoo-module **/
+
+import { registry } from "@web/core/registry";
+import { SelectionField } from "@web/views/fields/selection/selection_field";
+
+/**
+ * The purpose of this field is to be able to define some values which should not be
+ * displayed on our selection field, this way we can have multiple views for the same model
+ * that uses different possible sets of values on the same selection field.
+ */
+export class FilterableSelectionField extends SelectionField {
+ /**
+ * @override
+ */
+ get options() {
+ let options = super.options;
+ if (this.props.whitelisted_values) {
+ options = options.filter((option) => {
+ return option[0] === this.props.value || this.props.whitelisted_values.includes(option[0])
+ });
+ } else if (this.props.blacklisted_values) {
+ options = options.filter((option) => {
+ return option[0] === this.props.value || !this.props.blacklisted_values.includes(option[0]);
+ });
+ }
+ return options;
+ }
+};
+
+FilterableSelectionField.props = {
+ ...SelectionField.props,
+ whitelisted_values: { type: Array, optional: true },
+ blacklisted_values: { type: Array, optional: true },
+};
+
+FilterableSelectionField.extractProps = ({ attrs }) => {
+ return {
+ ...SelectionField.extractProps({ attrs }),
+ whitelisted_values: attrs.options.whitelisted_values,
+ blacklisted_values: attrs.options.blacklisted_values,
+ };
+};
+
+registry.category("fields").add("filterable_selection", FilterableSelectionField);
diff --git a/addons/loyalty/static/src/js/loyalty_list_view.js b/addons/loyalty/static/src/js/loyalty_list_view.js
index 9e13a97c6fb..7eb321fcb9d 100644
--- a/addons/loyalty/static/src/js/loyalty_list_view.js
+++ b/addons/loyalty/static/src/js/loyalty_list_view.js
@@ -16,6 +16,10 @@ export class LoyaltyActionHelper extends Component {
this.loyaltyTemplateData = await this.orm.call(
"loyalty.program",
"get_program_templates",
+ [],
+ {
+ context: this.env.model.root.context,
+ },
);
});
}
@@ -36,7 +40,10 @@ LoyaltyActionHelper.template = "loyalty.LoyaltyActionHelper";
export class LoyaltyListRenderer extends ListRenderer {};
LoyaltyListRenderer.template = "loyalty.LoyaltyListRenderer";
-LoyaltyListRenderer.components.LoyaltyActionHelper = LoyaltyActionHelper;
+LoyaltyListRenderer.components = {
+ ...LoyaltyListRenderer.components,
+ LoyaltyActionHelper,
+};
export const LoyaltyListView = {
...listView,
diff --git a/addons/loyalty/static/src/scss/loyalty.scss b/addons/loyalty/static/src/scss/loyalty.scss
index e7e1793eeb0..58c23e467a6 100644
--- a/addons/loyalty/static/src/scss/loyalty.scss
+++ b/addons/loyalty/static/src/scss/loyalty.scss
@@ -4,13 +4,22 @@
.o_kanban_renderer {
padding: 0px !important;
+
+ .o_kanban_record {
+ margin-right: 0px;
+ margin-left: 0px;
+
+ .o_kanban_tags .o_tag span {
+ // Remove the small ball before the tags
+ width: auto !important;
+ height: auto !important;
+ background-color: none !important;
+ }
+ }
}
- .o_kanban_record .o_kanban_tags .o_tag span {
- // Remove the small ball before the tags
- width: auto !important;
- height: auto !important;
- background-color: none !important;
+ .o_loyalty_kanban_card_right {
+ text-align: center;
}
}
@@ -54,3 +63,9 @@
width: 100%;
}
}
+
+.loyalty-program-list-view {
+ .o_view_nocontent {
+ top: 10% !important;
+ }
+}
diff --git a/addons/loyalty/static/src/xml/loyalty_templates.xml b/addons/loyalty/static/src/xml/loyalty_templates.xml
index 71147eda425..fa0fd4a2661 100644
--- a/addons/loyalty/static/src/xml/loyalty_templates.xml
+++ b/addons/loyalty/static/src/xml/loyalty_templates.xml
@@ -14,7 +14,7 @@
-
+
diff --git a/addons/loyalty/static/tests/views/filterable_selection_field_tests.js b/addons/loyalty/static/tests/views/filterable_selection_field_tests.js
new file mode 100644
index 00000000000..11a90ed71f5
--- /dev/null
+++ b/addons/loyalty/static/tests/views/filterable_selection_field_tests.js
@@ -0,0 +1,126 @@
+/** @odoo-module **/
+
+import { click, editSelect, getFixture } from "@web/../tests/helpers/utils";
+import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
+
+let serverData;
+let target;
+
+// Note: the containsN always check for one more as there will be an invisible empty option every time.
+QUnit.module("Fields", (hooks) => {
+ hooks.beforeEach(() => {
+ target = getFixture();
+ serverData = {
+ models: {
+ program: {
+ fields: {
+ program_type: {
+ type: "selection",
+ selection: [
+ ["coupon", "Coupons"],
+ ["promotion", "Promotion"],
+ ["gift_card", "gift_card"],
+ ],
+ required: true,
+ }
+ },
+ records: [
+ { id: 1, program_type: "coupon" },
+ { id: 2, program_type: "gift_card" },
+ ],
+ },
+ }
+ }
+ setupViewRegistries();
+ });
+
+ QUnit.module("Loyalty > FilterableSelectionField");
+
+ QUnit.test("FilterableSelectionField test whitelist", async (assert) => {
+ await makeView({
+ type: "form",
+ resModel: "program",
+ resId: 1,
+ serverData,
+ arch: `
+ `,
+ });
+ await click(target, ".o_form_button_edit");
+
+ assert.containsN(target, "select option", 3);
+ assert.containsOnce(
+ target,
+ ".o_field_widget[name='program_type'] select option[value='\"coupon\"']",
+ );
+ assert.containsOnce(
+ target,
+ ".o_field_widget[name='program_type'] select option[value='\"promotion\"']",
+ );
+ });
+
+ QUnit.test("FilterableSelectionField test blacklist", async (assert) => {
+ await makeView({
+ type: "form",
+ resModel: "program",
+ resId: 1,
+ serverData,
+ arch: `
+ `,
+ });
+ await click(target, ".o_form_button_edit");
+
+ assert.containsN(target, "select option", 3);
+ assert.containsOnce(
+ target,
+ ".o_field_widget[name='program_type'] select option[value='\"coupon\"']",
+ );
+ assert.containsOnce(
+ target,
+ ".o_field_widget[name='program_type'] select option[value='\"promotion\"']",
+ );
+ });
+
+ QUnit.test("FilterableSelectionField test with invalid value", async (assert) => {
+ // The field should still display the current value in the list
+ await makeView({
+ type: "form",
+ resModel: "program",
+ resId: 2,
+ serverData,
+ arch: `
+ `,
+ });
+ await click(target, ".o_form_button_edit");
+
+ assert.containsN(target, "select option", 4);
+ assert.containsOnce(
+ target,
+ ".o_field_widget[name='program_type'] select option[value='\"gift_card\"']",
+ );
+ assert.containsOnce(
+ target,
+ ".o_field_widget[name='program_type'] select option[value='\"coupon\"']",
+ );
+ assert.containsOnce(
+ target,
+ ".o_field_widget[name='program_type'] select option[value='\"promotion\"']",
+ );
+
+ await editSelect(target, ".o_field_widget[name='program_type'] select", '"coupon"');
+ assert.containsN(target, "select option", 3);
+ assert.containsOnce(
+ target,
+ ".o_field_widget[name='program_type'] select option[value='\"coupon\"']",
+ );
+ assert.containsOnce(
+ target,
+ ".o_field_widget[name='program_type'] select option[value='\"promotion\"']",
+ );
+ });
+});
diff --git a/addons/loyalty/views/loyalty_card_views.xml b/addons/loyalty/views/loyalty_card_views.xml
index e616aadcec2..9655a43c0bf 100644
--- a/addons/loyalty/views/loyalty_card_views.xml
+++ b/addons/loyalty/views/loyalty_card_views.xml
@@ -7,10 +7,16 @@