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 @@
- - - - + + + + +
@@ -26,7 +32,8 @@ - + + @@ -35,6 +42,17 @@ + + loyalty.card.view.search + loyalty.card + + + + + + + + Coupons loyalty.card diff --git a/addons/loyalty/views/loyalty_program_views.xml b/addons/loyalty/views/loyalty_program_views.xml index 593ae61a4cb..e4f3aaf59cc 100644 --- a/addons/loyalty/views/loyalty_program_views.xml +++ b/addons/loyalty/views/loyalty_program_views.xml @@ -1,10 +1,12 @@ + + loyalty.program.view.form loyalty.program - +
+
-
@@ -33,69 +37,88 @@
- -