Initial commit: Sale packages
BIN
odoo-bringout-oca-ocb-sale/sale/static/description/icon.png
Normal file
|
After Width: | Height: | Size: 6 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="70" height="70" viewBox="0 0 70 70"><defs><path id="a" d="M4 0h61c4 0 5 1 5 5v60c0 4-1 5-5 5H4c-3 0-4-1-4-5V5c0-4 1-5 4-5z"/><linearGradient id="c" x1="100%" x2="0%" y1="0%" y2="100%"><stop offset="0%" stop-color="#DA956B"/><stop offset="100%" stop-color="#CC7039"/></linearGradient><path id="d" d="M56.243 52.279c.578 0 1.05.537 1.05 1.193v.978c0 .656-.472 1.194-1.05 1.194H13.532c-.578 0-1.05-.538-1.05-1.194v-35.8c0-.657.472-1.194 1.05-1.194h1.5c.578 0 1.05.537 1.05 1.194v33.629h40.161zM39 23.025l4.981 4.963-6.302 7.25-4.866-5.53c-.411-.467-1.068-.467-1.48 0L20.92 41.423a1.31 1.31 0 0 0-.018 1.68l2.494 2.924c.412.477 1.086.487 1.497.01l7.186-8.165 4.857 5.52a.965.965 0 0 0 1.488 0l9.558-10.86L53 37.664 55 20l-16 3.025z"/><path id="e" d="M56.243 50.279c.578 0 1.05.537 1.05 1.193v.978c0 .656-.472 1.194-1.05 1.194H13.532c-.578 0-1.05-.538-1.05-1.194v-35.8c0-.657.472-1.194 1.05-1.194h1.5c.578 0 1.05.537 1.05 1.194v33.629h40.161zM39 21.025l4.981 4.963-6.302 7.25-4.866-5.53c-.411-.467-1.068-.467-1.48 0L20.92 39.423a1.31 1.31 0 0 0-.018 1.68l2.494 2.924c.412.477 1.086.487 1.497.01l7.186-8.165 4.857 5.52a.965.965 0 0 0 1.488 0l9.558-10.86L53 35.664 55 18l-16 3.025z"/></defs><g fill="none" fill-rule="evenodd"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><g mask="url(#b)"><path fill="url(#c)" d="M0 0H70V70H0z"/><path fill="#FFF" fill-opacity=".383" d="M4 1h61c2.667 0 4.333.667 5 2V0H0v3c.667-1.333 2-2 4-2z"/><path fill="#393939" d="M45.243 69H4c-2 0-4-.146-4-4.077V35.315L13 16h3v26.5l15-14.27.974.024L40 21.096l13 14.27-18 18.346h22L45.243 69z" opacity=".324"/><path fill="#000" fill-opacity=".383" d="M4 69h61c2.667 0 4.333-1 5-3v4H0v-4c.667 2 2 3 4 3z"/><use fill="#000" fill-rule="nonzero" opacity=".3" xlink:href="#d"/><use fill="#FFF" fill-rule="nonzero" xlink:href="#e"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
BIN
odoo-bringout-oca-ocb-sale/sale/static/img/btn_paynowcc_lg.gif
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
|
@ -0,0 +1,37 @@
|
|||
odoo.define('sale.payment_form', require => {
|
||||
'use strict';
|
||||
|
||||
const checkoutForm = require('payment.checkout_form');
|
||||
const manageForm = require('payment.manage_form');
|
||||
|
||||
const salePaymentMixin = {
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add `sale_order_id` to the transaction route params if it is provided.
|
||||
*
|
||||
* @override method from payment.payment_form_mixin
|
||||
* @private
|
||||
* @param {string} code - The code of the selected payment option's provider
|
||||
* @param {number} paymentOptionId - The id of the selected payment option
|
||||
* @param {string} flow - The online payment flow of the selected payment option
|
||||
* @return {object} The extended transaction route params
|
||||
*/
|
||||
_prepareTransactionRouteParams: function (code, paymentOptionId, flow) {
|
||||
const transactionRouteParams = this._super(...arguments);
|
||||
return {
|
||||
...transactionRouteParams,
|
||||
'sale_order_id': this.txContext.saleOrderId
|
||||
? parseInt(this.txContext.saleOrderId) : undefined,
|
||||
};
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
checkoutForm.include(salePaymentMixin);
|
||||
manageForm.include(salePaymentMixin);
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { FloatField } from "@web/views/fields/float/float_field";
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||
|
||||
/**
|
||||
* Dialog called if user changes a value in the sale order line.
|
||||
* The wizard will open only if
|
||||
* (1) Sale order line is 3 or more
|
||||
* (2) First sale order line is changed
|
||||
* (3) value is the same in all other sale order line
|
||||
*/
|
||||
|
||||
export class ProductDiscountField extends FloatField {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.dialogService = useService("dialog");
|
||||
}
|
||||
|
||||
onChange(ev) {
|
||||
if (!("order_line" in this.props.record.model.root.data)) {
|
||||
return;
|
||||
}
|
||||
const x2mList = this.props.record.model.root.data.order_line;
|
||||
const orderLines = x2mList.records.filter(line => !line.data.display_type);
|
||||
|
||||
if (orderLines.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFirstOrderLine = this.props.record.data.id === orderLines[0].data.id;
|
||||
if (isFirstOrderLine && sameValue(orderLines)) {
|
||||
this.dialogService.add(ConfirmationDialog, {
|
||||
body: _lt("Do you want to apply this value to all lines ?"),
|
||||
confirm: () => {
|
||||
const commands = orderLines.slice(1).map((line) => {
|
||||
return {
|
||||
operation: "UPDATE",
|
||||
record: line,
|
||||
data: {["discount"]: this.props.value},
|
||||
};
|
||||
});
|
||||
|
||||
x2mList.applyCommands('order_line', commands);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO remove this function, no need to export it anymore.
|
||||
export function sameValue(orderLines) {
|
||||
const compareValue = orderLines[1].data.discount;
|
||||
return orderLines.slice(1).every(line => line.data.discount === compareValue);
|
||||
}
|
||||
|
||||
|
||||
ProductDiscountField.components = { ConfirmationDialog };
|
||||
ProductDiscountField.template = "sale.ProductDiscountField";
|
||||
ProductDiscountField.displayName = _lt("Disc.%");
|
||||
|
||||
registry.category("fields").add("sol_discount", ProductDiscountField)
|
||||
13
odoo-bringout-oca-ocb-sale/sale/static/src/js/sale_portal.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/** @odoo-module */
|
||||
|
||||
import publicWidget from 'web.public.widget';
|
||||
import "portal.portal"; // force dependencies
|
||||
|
||||
publicWidget.registry.PortalHomeCounters.include({
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_getCountersAlwaysDisplayed() {
|
||||
return this._super(...arguments).concat(['quotation_count', 'order_count']);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
odoo.define('sale.SalePortalSidebar', function (require) {
|
||||
'use strict';
|
||||
|
||||
var publicWidget = require('web.public.widget');
|
||||
var PortalSidebar = require('portal.PortalSidebar');
|
||||
|
||||
publicWidget.registry.SalePortalSidebar = PortalSidebar.extend({
|
||||
selector: '.o_portal_sale_sidebar',
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
*/
|
||||
init: function (parent, options) {
|
||||
this._super.apply(this, arguments);
|
||||
this.authorizedTextTag = ['em', 'b', 'i', 'u'];
|
||||
this.spyWatched = $('body[data-target=".navspy"]');
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
start: function () {
|
||||
var def = this._super.apply(this, arguments);
|
||||
var $spyWatcheElement = this.$el.find('[data-id="portal_sidebar"]');
|
||||
this._setElementId($spyWatcheElement);
|
||||
// Nav Menu ScrollSpy
|
||||
this._generateMenu();
|
||||
// After signature, automatically open the popup for payment
|
||||
if ($.bbq.getState('allow_payment') === 'yes' && this.$('#o_sale_portal_paynow').length) {
|
||||
this.el.querySelector('#o_sale_portal_paynow').click();
|
||||
$.bbq.removeState('allow_payment');
|
||||
}
|
||||
return def;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* create an unique id and added as a attribute of spyWatched element
|
||||
*
|
||||
* @private
|
||||
* @param {string} prefix
|
||||
* @param {Object} $el
|
||||
*
|
||||
*/
|
||||
_setElementId: function (prefix, $el) {
|
||||
var id = _.uniqueId(prefix);
|
||||
this.spyWatched.find($el).attr('id', id);
|
||||
return id;
|
||||
},
|
||||
/**
|
||||
* generate the new spy menu
|
||||
*
|
||||
* @private
|
||||
*
|
||||
*/
|
||||
_generateMenu: function () {
|
||||
var self = this,
|
||||
lastLI = false,
|
||||
lastUL = null,
|
||||
$bsSidenav = this.$el.find('.bs-sidenav');
|
||||
|
||||
$("#quote_content [id^=quote_header_], #quote_content [id^=quote_]", this.spyWatched).attr("id", "");
|
||||
_.each(this.spyWatched.find("#quote_content h2, #quote_content h3"), function (el) {
|
||||
var id, text;
|
||||
switch (el.tagName.toLowerCase()) {
|
||||
case "h2":
|
||||
id = self._setElementId('quote_header_', el);
|
||||
text = self._extractText($(el));
|
||||
if (!text) {
|
||||
break;
|
||||
}
|
||||
lastLI = $("<li class='nav-item'>").append($('<a class="nav-link" style="max-width: 200px;" href="#' + id + '"/>').text(text)).appendTo($bsSidenav);
|
||||
lastUL = false;
|
||||
break;
|
||||
case "h3":
|
||||
id = self._setElementId('quote_', el);
|
||||
text = self._extractText($(el));
|
||||
if (!text) {
|
||||
break;
|
||||
}
|
||||
if (lastLI) {
|
||||
if (!lastUL) {
|
||||
lastUL = $("<ul class='nav flex-column'>").appendTo(lastLI);
|
||||
}
|
||||
$("<li class='nav-item'>").append($('<a class="nav-link" style="max-width: 200px;" href="#' + id + '"/>').text(text)).appendTo(lastUL);
|
||||
}
|
||||
break;
|
||||
}
|
||||
el.setAttribute('data-anchor', true);
|
||||
});
|
||||
this.trigger_up('widgets_start_request', {$target: $bsSidenav});
|
||||
},
|
||||
/**
|
||||
* extract text of menu title for sidebar
|
||||
*
|
||||
* @private
|
||||
* @param {Object} $node
|
||||
*
|
||||
*/
|
||||
_extractText: function ($node) {
|
||||
var self = this;
|
||||
var rawText = [];
|
||||
_.each($node.contents(), function (el) {
|
||||
var current = $(el);
|
||||
if ($.trim(current.text())) {
|
||||
var tagName = current.prop("tagName");
|
||||
if (_.isUndefined(tagName) || (!_.isUndefined(tagName) && _.contains(self.authorizedTextTag, tagName.toLowerCase()))) {
|
||||
rawText.push($.trim(current.text()));
|
||||
}
|
||||
}
|
||||
});
|
||||
return rawText.join(' ');
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from '@web/core/registry';
|
||||
import { Many2OneField } from '@web/views/fields/many2one/many2one_field';
|
||||
import { useEffect } from '@odoo/owl';
|
||||
|
||||
export class SaleOrderLineProductField extends Many2OneField {
|
||||
|
||||
setup() {
|
||||
super.setup();
|
||||
let isMounted = false;
|
||||
let isInternalUpdate = false;
|
||||
const super_update = this.update;
|
||||
this.update = (recordlist) => {
|
||||
isInternalUpdate = true;
|
||||
super_update(recordlist);
|
||||
};
|
||||
if (this.props.canQuickCreate) {
|
||||
this.quickCreate = (name) => {
|
||||
isInternalUpdate = true;
|
||||
return this.props.update([false, name]);
|
||||
};
|
||||
}
|
||||
useEffect(value => {
|
||||
if (!isMounted) {
|
||||
isMounted = true;
|
||||
} else if (value && isInternalUpdate) {
|
||||
// we don't want to trigger product update when update comes from an external sources,
|
||||
// such as an onchange, or the product configuration dialog itself
|
||||
if (this.props.relation === 'product.template') {
|
||||
this._onProductTemplateUpdate();
|
||||
} else {
|
||||
this._onProductUpdate();
|
||||
}
|
||||
}
|
||||
isInternalUpdate = false;
|
||||
}, () => [Array.isArray(this.value) && this.value[0]]);
|
||||
}
|
||||
|
||||
get isProductClickable() {
|
||||
// product form should be accessible if the widget field is readonly
|
||||
// or if the line cannot be edited (e.g. locked SO)
|
||||
return (
|
||||
this.props.record.isReadonly(this.props.name)
|
||||
|| this.props.record.model.root.isReadonly
|
||||
&& this.props.record.model.root.activeFields.order_line
|
||||
&& this.props.record.model.root.isReadonly('order_line')
|
||||
)
|
||||
}
|
||||
get hasExternalButton() {
|
||||
// Keep external button, even if field is specified as 'no_open' so that the user is not
|
||||
// redirected to the product when clicking on the field content
|
||||
const res = super.hasExternalButton;
|
||||
return res || (!!this.props.value && !this.state.isFloating);
|
||||
}
|
||||
get hasConfigurationButton() {
|
||||
return this.isConfigurableLine || this.isConfigurableTemplate;
|
||||
}
|
||||
get isConfigurableLine() { return false; }
|
||||
get isConfigurableTemplate() { return false; }
|
||||
|
||||
get configurationButtonHelp() {
|
||||
return this.env._t("Edit Configuration");
|
||||
}
|
||||
|
||||
get configurationButtonIcon() {
|
||||
return 'btn btn-secondary fa ' + this.configurationButtonFAIcon();
|
||||
}
|
||||
|
||||
configurationButtonFAIcon() {
|
||||
return 'fa-pencil';
|
||||
}
|
||||
|
||||
onClick(ev) {
|
||||
// Override to get internal link to products in SOL that cannot be edited
|
||||
if (this.props.readonly) {
|
||||
ev.stopPropagation();
|
||||
this.openAction();
|
||||
}
|
||||
else {
|
||||
super.onClick(ev);
|
||||
}
|
||||
}
|
||||
|
||||
async _onProductTemplateUpdate() { }
|
||||
async _onProductUpdate() { } // event_booth_sale, event_sale, sale_renting
|
||||
|
||||
onEditConfiguration() {
|
||||
if (this.isConfigurableLine) {
|
||||
this._editLineConfiguration();
|
||||
} else {
|
||||
this._editProductConfiguration();
|
||||
}
|
||||
}
|
||||
_editLineConfiguration() { } // event_booth_sale, event_sale, sale_renting
|
||||
_editProductConfiguration() { } // sale_product_configurator, sale_product_matrix
|
||||
|
||||
}
|
||||
|
||||
SaleOrderLineProductField.template = "sale.SaleProductField";
|
||||
|
||||
registry.category("fields").add("sol_product_many2one", SaleOrderLineProductField);
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { KanbanProgressBarField } from "@web/views/fields/progress_bar/kanban_progress_bar_field";
|
||||
|
||||
const { useEffect } = owl;
|
||||
|
||||
/**
|
||||
* A custom Component for the view of sales teams on the kanban view in the CRM app.
|
||||
*
|
||||
* The wanted behavior is to show a progress bar when an invoicing target is defined or show
|
||||
* a link redirecting to the record's form view otherwise.
|
||||
*/
|
||||
export class SaleProgressBarField extends KanbanProgressBarField {
|
||||
/**
|
||||
* Anything used by the component is defined on the setup method.
|
||||
*/
|
||||
setup() {
|
||||
super.setup();
|
||||
|
||||
this.actionService = useService("action");
|
||||
this.orm = useService("orm");
|
||||
|
||||
useEffect(() => {
|
||||
this.state.isInvoicingTargetDefined = this.props.record.data[this.props.maxValueField];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the form view of the record on click.
|
||||
*/
|
||||
async defineInvoicingTarget() {
|
||||
const { resId, resModel } = this.props.record;
|
||||
const action = await this.orm.call(resModel, "get_formview_action", [[resId]]);
|
||||
this.actionService.doAction(action, { props: { mode: "edit" } });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the template name used on the component.
|
||||
*/
|
||||
SaleProgressBarField.template = "sale.SaleProgressBarField";
|
||||
|
||||
registry.category("fields").add("sales_team_progressbar", SaleProgressBarField);
|
||||
129
odoo-bringout-oca-ocb-sale/sale/static/src/js/tours/sale.js
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
odoo.define('sale.tour', function(require) {
|
||||
"use strict";
|
||||
|
||||
const {_t} = require('web.core');
|
||||
const {Markup} = require('web.utils');
|
||||
var tour = require('web_tour.tour');
|
||||
|
||||
const { markup } = owl;
|
||||
|
||||
tour.register("sale_tour", {
|
||||
url: "/web",
|
||||
rainbowMan: false,
|
||||
sequence: 20,
|
||||
}, [tour.stepUtils.showAppsMenuItem(), {
|
||||
trigger: ".o_app[data-menu-xmlid='sale.sale_menu_root']",
|
||||
content: _t("Open Sales app to send your first quotation in a few clicks."),
|
||||
position: "right",
|
||||
edition: "community"
|
||||
}, {
|
||||
trigger: ".o_app[data-menu-xmlid='sale.sale_menu_root']",
|
||||
content: _t("Open Sales app to send your first quotation in a few clicks."),
|
||||
position: "bottom",
|
||||
edition: "enterprise"
|
||||
}, {
|
||||
trigger: 'a.o_onboarding_step_action.btn[data-method=action_open_base_onboarding_company]',
|
||||
extra_trigger: ".o_sale_order",
|
||||
content: _t("Start by checking your company's data."),
|
||||
position: "bottom",
|
||||
skip_trigger: 'a[data-method=action_open_base_onboarding_company].o_onboarding_step_action__done',
|
||||
}, {
|
||||
trigger: 'input[id=street]',
|
||||
content: _t("Complete your company's data"),
|
||||
position: "bottom",
|
||||
skip_trigger: 'a[data-method=action_open_base_onboarding_company].o_onboarding_step_action__done',
|
||||
}, {
|
||||
trigger: ".modal-content button[name='action_save_onboarding_company_step']",
|
||||
content: _t("Looks good. Let's continue."),
|
||||
position: "left",
|
||||
skip_trigger: 'a[data-method=action_open_base_onboarding_company].o_onboarding_step_action__done',
|
||||
}, {
|
||||
trigger: 'a.o_onboarding_step_action.btn[data-method=action_open_base_document_layout]',
|
||||
extra_trigger: ".o_sale_order",
|
||||
content: _t("Customize your quotes and orders."),
|
||||
position: "bottom",
|
||||
skip_trigger: 'a[data-method=action_open_base_document_layout].o_onboarding_step_action__done',
|
||||
}, {
|
||||
trigger: "button[name='document_layout_save']",
|
||||
extra_trigger: ".o_sale_order",
|
||||
content: _t("Good job, let's continue."),
|
||||
position: "top", // dot NOT move to bottom, it would cause a resize flicker
|
||||
skip_trigger: 'a[data-method=action_open_base_document_layout].o_onboarding_step_action__done',
|
||||
}, {
|
||||
trigger: 'a.o_onboarding_step_action.btn[data-method=action_open_sale_onboarding_payment_provider]',
|
||||
extra_trigger: ".o_sale_order",
|
||||
content: _t("To speed up order confirmation, we can activate electronic signatures or payments."),
|
||||
position: "bottom",
|
||||
skip_trigger: 'a[data-method=action_open_sale_onboarding_payment_provider].o_onboarding_step_action__done',
|
||||
}, {
|
||||
trigger: "button[name='add_payment_methods']",
|
||||
extra_trigger: ".o_sale_order",
|
||||
content: _t("Lets keep electronic signature for now."),
|
||||
position: "bottom",
|
||||
skip_trigger: 'a[data-method=action_open_sale_onboarding_payment_provider].o_onboarding_step_action__done',
|
||||
}, {
|
||||
trigger: 'a.o_onboarding_step_action.btn[data-method=action_open_sale_onboarding_sample_quotation]',
|
||||
extra_trigger: ".o_sale_order",
|
||||
content: _t("Now, we'll create a sample quote."),
|
||||
position: "bottom",
|
||||
}]);
|
||||
|
||||
tour.register("sale_quote_tour", {
|
||||
url: "/web#action=sale.action_quotations_with_onboarding&view_type=form",
|
||||
rainbowMan: true,
|
||||
rainbowManMessage: markup(_t("<b>Congratulations</b>, your first quotation is sent!<br>Check your email to validate the quote.")),
|
||||
sequence: 30,
|
||||
}, [{
|
||||
trigger: ".o_field_res_partner_many2one[name='partner_id']",
|
||||
extra_trigger: ".o_sale_order",
|
||||
content: _t("Write a company name to create one, or see suggestions."),
|
||||
position: "right",
|
||||
run: function (actions) {
|
||||
actions.text("Agrolait", this.$anchor.find("input"));
|
||||
},
|
||||
}, {
|
||||
trigger: ".ui-menu-item > a:contains('Agrolait')",
|
||||
auto: true,
|
||||
in_modal: false,
|
||||
}, {
|
||||
trigger: ".o_field_x2many_list_row_add > a",
|
||||
content: _t("Click here to add some products or services to your quotation."),
|
||||
position: "bottom",
|
||||
}, {
|
||||
trigger: ".o_field_widget[name='product_id'], .o_field_widget[name='product_template_id']",
|
||||
extra_trigger: ".o_sale_order",
|
||||
content: _t("Select a product, or create a new one on the fly."),
|
||||
position: "right",
|
||||
run: function (actions) {
|
||||
var $input = this.$anchor.find("input");
|
||||
actions.text("DESK0001", $input.length === 0 ? this.$anchor : $input);
|
||||
// fake keydown to trigger search
|
||||
var keyDownEvent = jQuery.Event("keydown");
|
||||
keyDownEvent.which = 42;
|
||||
this.$anchor.trigger(keyDownEvent);
|
||||
var $descriptionElement = $(".o_form_editable textarea[name='name']");
|
||||
// when description changes, we know the product has been created
|
||||
$descriptionElement.change(function () {
|
||||
$descriptionElement.addClass("product_creation_success");
|
||||
});
|
||||
},
|
||||
id: "product_selection_step"
|
||||
}, {
|
||||
trigger: "a:contains('DESK0001')",
|
||||
auto: true,
|
||||
}, {
|
||||
trigger: ".o_field_widget[name='price_unit'] ",
|
||||
extra_trigger: ".fa-arrow-right", // Wait for product creation
|
||||
content: Markup(_t("<b>Set a price</b>.")),
|
||||
position: "right",
|
||||
run: "text 10.0"
|
||||
},
|
||||
...tour.stepUtils.statusbarButtonsSteps("Send by Email", Markup(_t("<b>Send the quote</b> to yourself and check what the customer will receive.")), ".o_statusbar_buttons button[name='action_quotation_send']"),
|
||||
{
|
||||
trigger: ".modal-footer button[name='action_send_mail']",
|
||||
extra_trigger: ".modal-footer button[name='action_send_mail']",
|
||||
content: _t("Let's send the quote."),
|
||||
position: "bottom",
|
||||
}]);
|
||||
|
||||
});
|
||||
758
odoo-bringout-oca-ocb-sale/sale/static/src/js/variant_mixin.js
Normal file
|
|
@ -0,0 +1,758 @@
|
|||
odoo.define('sale.VariantMixin', function (require) {
|
||||
'use strict';
|
||||
|
||||
var concurrency = require('web.concurrency');
|
||||
var core = require('web.core');
|
||||
var utils = require('web.utils');
|
||||
var ajax = require('web.ajax');
|
||||
var session = require('web.session');
|
||||
var _t = core._t;
|
||||
|
||||
var VariantMixin = {
|
||||
events: {
|
||||
'change .css_attribute_color input': '_onChangeColorAttribute',
|
||||
'click .o_variant_pills': '_onChangePillsAttribute',
|
||||
'change .main_product:not(.in_cart) input.js_quantity': 'onChangeAddQuantity',
|
||||
'change [data-attribute_exclusions]': 'onChangeVariant'
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Public
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* When a variant is changed, this will check:
|
||||
* - If the selected combination is available or not
|
||||
* - The extra price if applicable
|
||||
* - The display name of the product ("Customizable desk (White, Steel)")
|
||||
* - The new total price
|
||||
* - The need of adding a "custom value" input
|
||||
* If the custom value is the only available value
|
||||
* (defined by its data 'is_single_and_custom'),
|
||||
* the custom value will have it's own input & label
|
||||
*
|
||||
* 'change' events triggered by the user entered custom values are ignored since they
|
||||
* are not relevant
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onChangeVariant: function (ev) {
|
||||
var $parent = $(ev.target).closest('.js_product');
|
||||
if (!$parent.data('uniqueId')) {
|
||||
$parent.data('uniqueId', _.uniqueId());
|
||||
}
|
||||
this._throttledGetCombinationInfo($parent.data('uniqueId'))(ev);
|
||||
},
|
||||
/**
|
||||
* @see onChangeVariant
|
||||
*
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
* @returns {Deferred}
|
||||
*/
|
||||
_getCombinationInfo: function (ev) {
|
||||
if ($(ev.target).hasClass('variant_custom_value')) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const $parent = $(ev.target).closest('.js_product');
|
||||
if(!$parent.length){
|
||||
return Promise.resolve();
|
||||
}
|
||||
const combination = this.getSelectedVariantValues($parent);
|
||||
let parentCombination;
|
||||
|
||||
if ($parent.hasClass('main_product')) {
|
||||
parentCombination = $parent.find('ul[data-attribute_exclusions]').data('attribute_exclusions').parent_combination;
|
||||
const $optProducts = $parent.parent().find(`[data-parent-unique-id='${$parent.data('uniqueId')}']`);
|
||||
|
||||
for (const optionalProduct of $optProducts) {
|
||||
const $currentOptionalProduct = $(optionalProduct);
|
||||
const childCombination = this.getSelectedVariantValues($currentOptionalProduct);
|
||||
const productTemplateId = parseInt($currentOptionalProduct.find('.product_template_id').val());
|
||||
ajax.jsonRpc(this._getUri('/sale/get_combination_info'), 'call', {
|
||||
'product_template_id': productTemplateId,
|
||||
'product_id': this._getProductId($currentOptionalProduct),
|
||||
'combination': childCombination,
|
||||
'add_qty': parseInt($currentOptionalProduct.find('input[name="add_qty"]').val()),
|
||||
'pricelist_id': this.pricelistId || false,
|
||||
'parent_combination': combination,
|
||||
'context': session.user_context,
|
||||
...this._getOptionalCombinationInfoParam($currentOptionalProduct),
|
||||
}).then((combinationData) => {
|
||||
if (this._shouldIgnoreRpcResult()) {
|
||||
return;
|
||||
}
|
||||
this._onChangeCombination(ev, $currentOptionalProduct, combinationData);
|
||||
this._checkExclusions($currentOptionalProduct, childCombination, combinationData.parent_exclusions);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
parentCombination = this.getSelectedVariantValues(
|
||||
$parent.parent().find('.js_product.in_cart.main_product')
|
||||
);
|
||||
}
|
||||
|
||||
return ajax.jsonRpc(this._getUri('/sale/get_combination_info'), 'call', {
|
||||
'product_template_id': parseInt($parent.find('.product_template_id').val()),
|
||||
'product_id': this._getProductId($parent),
|
||||
'combination': combination,
|
||||
'add_qty': parseInt($parent.find('input[name="add_qty"]').val()),
|
||||
'pricelist_id': this.pricelistId || false,
|
||||
'parent_combination': parentCombination,
|
||||
'context': session.user_context,
|
||||
...this._getOptionalCombinationInfoParam($parent),
|
||||
}).then((combinationData) => {
|
||||
if (this._shouldIgnoreRpcResult()) {
|
||||
return;
|
||||
}
|
||||
this._onChangeCombination(ev, $parent, combinationData);
|
||||
this._checkExclusions($parent, combination, combinationData.parent_exclusions);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Hook to add optional info to the combination info call.
|
||||
*
|
||||
* @param {$.Element} $product
|
||||
*/
|
||||
_getOptionalCombinationInfoParam($product) {
|
||||
return {};
|
||||
},
|
||||
|
||||
/**
|
||||
* Will add the "custom value" input for this attribute value if
|
||||
* the attribute value is configured as "custom" (see product_attribute_value.is_custom)
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
handleCustomValues: function ($target) {
|
||||
var $variantContainer;
|
||||
var $customInput = false;
|
||||
if ($target.is('input[type=radio]') && $target.is(':checked')) {
|
||||
$variantContainer = $target.closest('ul').closest('li');
|
||||
$customInput = $target;
|
||||
} else if ($target.is('select')) {
|
||||
$variantContainer = $target.closest('li');
|
||||
$customInput = $target
|
||||
.find('option[value="' + $target.val() + '"]');
|
||||
}
|
||||
|
||||
if ($variantContainer) {
|
||||
if ($customInput && $customInput.data('is_custom') === 'True') {
|
||||
var attributeValueId = $customInput.data('value_id');
|
||||
var attributeValueName = $customInput.data('value_name');
|
||||
|
||||
if ($variantContainer.find('.variant_custom_value').length === 0
|
||||
|| $variantContainer
|
||||
.find('.variant_custom_value')
|
||||
.data('custom_product_template_attribute_value_id') !== parseInt(attributeValueId)) {
|
||||
$variantContainer.find('.variant_custom_value').remove();
|
||||
|
||||
const previousCustomValue = $customInput.attr("previous_custom_value");
|
||||
var $input = $('<input>', {
|
||||
type: 'text',
|
||||
'data-custom_product_template_attribute_value_id': attributeValueId,
|
||||
'data-attribute_value_name': attributeValueName,
|
||||
class: 'variant_custom_value form-control mt-2'
|
||||
});
|
||||
|
||||
$input.attr('placeholder', attributeValueName);
|
||||
$input.addClass('custom_value_radio');
|
||||
$variantContainer.append($input);
|
||||
if (previousCustomValue) {
|
||||
$input.val(previousCustomValue);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$variantContainer.find('.variant_custom_value').remove();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Hack to add and remove from cart with json
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onClickAddCartJSON: function (ev) {
|
||||
ev.preventDefault();
|
||||
var $link = $(ev.currentTarget);
|
||||
var $input = $link.closest('.input-group').find("input");
|
||||
var min = parseFloat($input.data("min") || 0);
|
||||
var max = parseFloat($input.data("max") || Infinity);
|
||||
var previousQty = parseFloat($input.val() || 0, 10);
|
||||
var quantity = ($link.has(".fa-minus").length ? -1 : 1) + previousQty;
|
||||
var newQty = quantity > min ? (quantity < max ? quantity : max) : min;
|
||||
|
||||
if (newQty !== previousQty) {
|
||||
$input.val(newQty).trigger('change');
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* When the quantity is changed, we need to query the new price of the product.
|
||||
* Based on the price list, the price might change when quantity exceeds X
|
||||
*
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
onChangeAddQuantity: function (ev) {
|
||||
var $parent;
|
||||
|
||||
if ($(ev.currentTarget).closest('.oe_advanced_configurator_modal').length > 0){
|
||||
$parent = $(ev.currentTarget).closest('.oe_advanced_configurator_modal');
|
||||
} else if ($(ev.currentTarget).closest('form').length > 0){
|
||||
$parent = $(ev.currentTarget).closest('form');
|
||||
} else {
|
||||
$parent = $(ev.currentTarget).closest('.o_product_configurator');
|
||||
}
|
||||
|
||||
this.triggerVariantChange($parent);
|
||||
},
|
||||
|
||||
/**
|
||||
* Triggers the price computation and other variant specific changes
|
||||
*
|
||||
* @param {$.Element} $container
|
||||
*/
|
||||
triggerVariantChange: function ($container) {
|
||||
$container.find('ul[data-attribute_exclusions]').trigger('change');
|
||||
$container.find('input.js_variant_change:checked, select.js_variant_change').each(function () {
|
||||
VariantMixin.handleCustomValues($(this));
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Will look for user custom attribute values
|
||||
* in the provided container
|
||||
*
|
||||
* @param {$.Element} $container
|
||||
* @returns {Array} array of custom values with the following format
|
||||
* {integer} custom_product_template_attribute_value_id
|
||||
* {string} attribute_value_name
|
||||
* {string} custom_value
|
||||
*/
|
||||
getCustomVariantValues: function ($container) {
|
||||
var variantCustomValues = [];
|
||||
$container.find('.variant_custom_value').each(function (){
|
||||
var $variantCustomValueInput = $(this);
|
||||
if ($variantCustomValueInput.length !== 0){
|
||||
variantCustomValues.push({
|
||||
'custom_product_template_attribute_value_id': $variantCustomValueInput.data('custom_product_template_attribute_value_id'),
|
||||
'attribute_value_name': $variantCustomValueInput.data('attribute_value_name'),
|
||||
'custom_value': $variantCustomValueInput.val(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return variantCustomValues;
|
||||
},
|
||||
|
||||
/**
|
||||
* Will look for attribute values that do not create product variant
|
||||
* (see product_attribute.create_variant "dynamic")
|
||||
*
|
||||
* @param {$.Element} $container
|
||||
* @returns {Array} array of attribute values with the following format
|
||||
* {integer} custom_product_template_attribute_value_id
|
||||
* {string} attribute_value_name
|
||||
* {integer} value
|
||||
* {string} attribute_name
|
||||
* {boolean} is_custom
|
||||
*/
|
||||
getNoVariantAttributeValues: function ($container) {
|
||||
var noVariantAttributeValues = [];
|
||||
var variantsValuesSelectors = [
|
||||
'input.no_variant.js_variant_change:checked',
|
||||
'select.no_variant.js_variant_change'
|
||||
];
|
||||
|
||||
$container.find(variantsValuesSelectors.join(',')).each(function (){
|
||||
var $variantValueInput = $(this);
|
||||
var singleNoCustom = $variantValueInput.data('is_single') && !$variantValueInput.data('is_custom');
|
||||
|
||||
if ($variantValueInput.is('select')){
|
||||
$variantValueInput = $variantValueInput.find('option[value=' + $variantValueInput.val() + ']');
|
||||
}
|
||||
|
||||
if ($variantValueInput.length !== 0 && !singleNoCustom){
|
||||
noVariantAttributeValues.push({
|
||||
'custom_product_template_attribute_value_id': $variantValueInput.data('value_id'),
|
||||
'attribute_value_name': $variantValueInput.data('value_name'),
|
||||
'value': $variantValueInput.val(),
|
||||
'attribute_name': $variantValueInput.data('attribute_name'),
|
||||
'is_custom': $variantValueInput.data('is_custom')
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return noVariantAttributeValues;
|
||||
},
|
||||
|
||||
/**
|
||||
* Will return the list of selected product.template.attribute.value ids
|
||||
* For the modal, the "main product"'s attribute values are stored in the
|
||||
* "unchanged_value_ids" data
|
||||
*
|
||||
* @param {$.Element} $container the container to look into
|
||||
*/
|
||||
getSelectedVariantValues: function ($container) {
|
||||
var values = [];
|
||||
var unchangedValues = $container
|
||||
.find('div.oe_unchanged_value_ids')
|
||||
.data('unchanged_value_ids') || [];
|
||||
|
||||
var variantsValuesSelectors = [
|
||||
'input.js_variant_change:checked',
|
||||
'select.js_variant_change'
|
||||
];
|
||||
_.each($container.find(variantsValuesSelectors.join(', ')), function (el) {
|
||||
values.push(+$(el).val());
|
||||
});
|
||||
|
||||
return values.concat(unchangedValues);
|
||||
},
|
||||
|
||||
/**
|
||||
* Will return a promise:
|
||||
*
|
||||
* - If the product already exists, immediately resolves it with the product_id
|
||||
* - If the product does not exist yet ("dynamic" variant creation), this method will
|
||||
* create the product first and then resolve the promise with the created product's id
|
||||
*
|
||||
* @param {$.Element} $container the container to look into
|
||||
* @param {integer} productId the product id
|
||||
* @param {integer} productTemplateId the corresponding product template id
|
||||
* @param {boolean} useAjax wether the rpc call should be done using ajax.jsonRpc or using _rpc
|
||||
* @returns {Promise} the promise that will be resolved with a {integer} productId
|
||||
*/
|
||||
selectOrCreateProduct: function ($container, productId, productTemplateId, useAjax) {
|
||||
productId = parseInt(productId);
|
||||
productTemplateId = parseInt(productTemplateId);
|
||||
var productReady = Promise.resolve();
|
||||
if (productId) {
|
||||
productReady = Promise.resolve(productId);
|
||||
} else {
|
||||
var params = {
|
||||
product_template_id: productTemplateId,
|
||||
product_template_attribute_value_ids:
|
||||
JSON.stringify(VariantMixin.getSelectedVariantValues($container)),
|
||||
};
|
||||
|
||||
var route = '/sale/create_product_variant';
|
||||
if (useAjax) {
|
||||
productReady = ajax.jsonRpc(route, 'call', params);
|
||||
} else if (Boolean(this._rpc)) {
|
||||
// HACK to combine owl and non owl calls
|
||||
productReady = this._rpc({route: route, params: params});
|
||||
} else {
|
||||
productReady = this.rpc(route, params);
|
||||
}
|
||||
}
|
||||
|
||||
return productReady;
|
||||
},
|
||||
|
||||
//--------------------------------------------------------------------------
|
||||
// Private
|
||||
//--------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Will disable attribute value's inputs based on combination exclusions
|
||||
* and will disable the "add" button if the selected combination
|
||||
* is not available
|
||||
*
|
||||
* This will check both the exclusions within the product itself and
|
||||
* the exclusions coming from the parent product (meaning that this product
|
||||
* is an option of the parent product)
|
||||
*
|
||||
* It will also check that the selected combination does not exactly
|
||||
* match a manually archived product
|
||||
*
|
||||
* @private
|
||||
* @param {$.Element} $parent the parent container to apply exclusions
|
||||
* @param {Array} combination the selected combination of product attribute values
|
||||
* @param {Array} parentExclusions the exclusions induced by the variant selection of the parent product
|
||||
* For example chair cannot have steel legs if the parent Desk doesn't have steel legs
|
||||
*/
|
||||
_checkExclusions: function ($parent, combination, parentExclusions) {
|
||||
var self = this;
|
||||
var combinationData = $parent
|
||||
.find('ul[data-attribute_exclusions]')
|
||||
.data('attribute_exclusions');
|
||||
|
||||
if (parentExclusions && combinationData.parent_exclusions) {
|
||||
combinationData.parent_exclusions = parentExclusions;
|
||||
}
|
||||
$parent
|
||||
.find('option, input, label, .o_variant_pills')
|
||||
.removeClass('css_not_available')
|
||||
.attr('title', function () { return $(this).data('value_name') || ''; })
|
||||
.data('excluded-by', '');
|
||||
|
||||
// exclusion rules: array of ptav
|
||||
// for each of them, contains array with the other ptav they exclude
|
||||
if (combinationData.exclusions) {
|
||||
// browse all the currently selected attributes
|
||||
_.each(combination, function (current_ptav) {
|
||||
if (combinationData.exclusions.hasOwnProperty(current_ptav)) {
|
||||
// for each exclusion of the current attribute:
|
||||
_.each(combinationData.exclusions[current_ptav], function (excluded_ptav) {
|
||||
// disable the excluded input (even when not already selected)
|
||||
// to give a visual feedback before click
|
||||
self._disableInput(
|
||||
$parent,
|
||||
excluded_ptav,
|
||||
current_ptav,
|
||||
combinationData.mapped_attribute_names
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
// combination exclusions: array of array of ptav
|
||||
// for example a product with 3 variation and one specific variation is disabled (archived)
|
||||
// requires the first 2 to be selected for the third to be disabled
|
||||
if (combinationData.archived_combinations) {
|
||||
combinationData.archived_combinations.forEach((excludedCombination) => {
|
||||
const ptavCommon = excludedCombination.filter((ptav) => combination.includes(ptav));
|
||||
if (
|
||||
!!ptavCommon
|
||||
&& (combination.length === excludedCombination.length)
|
||||
&& (ptavCommon.length === combination.length)
|
||||
) {
|
||||
// Selected combination is archived, all attributes must be disabled from each other
|
||||
combination.forEach((ptav) => {
|
||||
combination.forEach((ptavOther) => {
|
||||
if (ptav === ptavOther) {
|
||||
return;
|
||||
}
|
||||
self._disableInput(
|
||||
$parent,
|
||||
ptav,
|
||||
ptavOther,
|
||||
combinationData.mapped_attribute_names,
|
||||
);
|
||||
})
|
||||
})
|
||||
} else if (
|
||||
!!ptavCommon
|
||||
&& (combination.length === excludedCombination.length)
|
||||
&& (ptavCommon.length === (combination.length - 1))
|
||||
) {
|
||||
// In this case we only need to disable the remaining ptav
|
||||
const disabledPtav = excludedCombination.find((ptav) => !combination.includes(ptav));
|
||||
excludedCombination.forEach((ptav) => {
|
||||
if (ptav === disabledPtav) {
|
||||
return;
|
||||
}
|
||||
self._disableInput(
|
||||
$parent,
|
||||
disabledPtav,
|
||||
ptav,
|
||||
combinationData.mapped_attribute_names,
|
||||
)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// parent exclusions (tell which attributes are excluded from parent)
|
||||
_.each(combinationData.parent_exclusions, function (exclusions, excluded_by){
|
||||
// check that the selected combination is in the parent exclusions
|
||||
_.each(exclusions, function (ptav) {
|
||||
|
||||
// disable the excluded input (even when not already selected)
|
||||
// to give a visual feedback before click
|
||||
self._disableInput(
|
||||
$parent,
|
||||
ptav,
|
||||
excluded_by,
|
||||
combinationData.mapped_attribute_names,
|
||||
combinationData.parent_product_name
|
||||
);
|
||||
});
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Extracted to a method to be extendable by other modules
|
||||
*
|
||||
* @param {$.Element} $parent
|
||||
*/
|
||||
_getProductId: function ($parent) {
|
||||
return parseInt($parent.find('.product_id').val());
|
||||
},
|
||||
/**
|
||||
* Will disable the input/option that refers to the passed attributeValueId.
|
||||
* This is used for showing the user that some combinations are not available.
|
||||
*
|
||||
* It will also display a message explaining why the input is not selectable.
|
||||
* Based on the "excludedBy" and the "productName" params.
|
||||
* e.g: Not available with Color: Black
|
||||
*
|
||||
* @private
|
||||
* @param {$.Element} $parent
|
||||
* @param {integer} attributeValueId
|
||||
* @param {integer} excludedBy The attribute value that excludes this input
|
||||
* @param {Object} attributeNames A dict containing all the names of the attribute values
|
||||
* to show a human readable message explaining why the input is disabled.
|
||||
* @param {string} [productName] The parent product. If provided, it will be appended before
|
||||
* the name of the attribute value that excludes this input
|
||||
* e.g: Not available with Customizable Desk (Color: Black)
|
||||
*/
|
||||
_disableInput: function ($parent, attributeValueId, excludedBy, attributeNames, productName) {
|
||||
var $input = $parent
|
||||
.find('option[value=' + attributeValueId + '], input[value=' + attributeValueId + ']');
|
||||
$input.addClass('css_not_available');
|
||||
$input.closest('label').addClass('css_not_available');
|
||||
$input.closest('.o_variant_pills').addClass('css_not_available');
|
||||
|
||||
if (excludedBy && attributeNames) {
|
||||
var $target = $input.is('option') ? $input : $input.closest('label').add($input);
|
||||
var excludedByData = [];
|
||||
if ($target.data('excluded-by')) {
|
||||
excludedByData = JSON.parse($target.data('excluded-by'));
|
||||
}
|
||||
|
||||
var excludedByName = attributeNames[excludedBy];
|
||||
if (productName) {
|
||||
excludedByName = productName + ' (' + excludedByName + ')';
|
||||
}
|
||||
excludedByData.push(excludedByName);
|
||||
|
||||
$target.attr('title', _.str.sprintf(_t('Not available with %s'), excludedByData.join(', ')));
|
||||
$target.data('excluded-by', JSON.stringify(excludedByData));
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @see onChangeVariant
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
* @param {$.Element} $parent
|
||||
* @param {Array} combination
|
||||
*/
|
||||
_onChangeCombination: function (ev, $parent, combination) {
|
||||
var self = this;
|
||||
var $price = $parent.find(".oe_price:first .oe_currency_value");
|
||||
var $default_price = $parent.find(".oe_default_price:first .oe_currency_value");
|
||||
var $optional_price = $parent.find(".oe_optional:first .oe_currency_value");
|
||||
$price.text(self._priceToStr(combination.price));
|
||||
$default_price.text(self._priceToStr(combination.list_price));
|
||||
|
||||
var isCombinationPossible = true;
|
||||
if (!_.isUndefined(combination.is_combination_possible)) {
|
||||
isCombinationPossible = combination.is_combination_possible;
|
||||
}
|
||||
this._toggleDisable($parent, isCombinationPossible);
|
||||
|
||||
if (combination.has_discounted_price && !combination.compare_list_price) {
|
||||
$default_price
|
||||
.closest('.oe_website_sale')
|
||||
.addClass("discount");
|
||||
$optional_price
|
||||
.closest('.oe_optional')
|
||||
.removeClass('d-none')
|
||||
.css('text-decoration', 'line-through');
|
||||
$default_price.parent().removeClass('d-none');
|
||||
} else {
|
||||
$default_price
|
||||
.closest('.oe_website_sale')
|
||||
.removeClass("discount");
|
||||
$optional_price.closest('.oe_optional').addClass('d-none');
|
||||
$default_price.parent().addClass('d-none');
|
||||
}
|
||||
|
||||
var rootComponentSelectors = [
|
||||
'tr.js_product',
|
||||
'.oe_website_sale',
|
||||
'.o_product_configurator'
|
||||
];
|
||||
|
||||
// update images only when changing product
|
||||
// or when either ids are 'false', meaning dynamic products.
|
||||
// Dynamic products don't have images BUT they may have invalid
|
||||
// combinations that need to disable the image.
|
||||
if (!combination.product_id ||
|
||||
!this.last_product_id ||
|
||||
combination.product_id !== this.last_product_id) {
|
||||
this.last_product_id = combination.product_id;
|
||||
self._updateProductImage(
|
||||
$parent.closest(rootComponentSelectors.join(', ')),
|
||||
combination.display_image,
|
||||
combination.product_id,
|
||||
combination.product_template_id,
|
||||
combination.carousel,
|
||||
isCombinationPossible
|
||||
);
|
||||
}
|
||||
|
||||
$parent
|
||||
.find('.product_id')
|
||||
.first()
|
||||
.val(combination.product_id || 0)
|
||||
.trigger('change');
|
||||
|
||||
$parent
|
||||
.find('.product_display_name')
|
||||
.first()
|
||||
.text(combination.display_name);
|
||||
|
||||
$parent
|
||||
.find('.js_raw_price')
|
||||
.first()
|
||||
.text(combination.price)
|
||||
.trigger('change');
|
||||
|
||||
this.handleCustomValues($(ev.target));
|
||||
},
|
||||
|
||||
/**
|
||||
* returns the formatted price
|
||||
*
|
||||
* @private
|
||||
* @param {float} price
|
||||
*/
|
||||
_priceToStr: function (price) {
|
||||
var l10n = _t.database.parameters;
|
||||
var precision = 2;
|
||||
|
||||
if ($('.decimal_precision').length) {
|
||||
precision = parseInt($('.decimal_precision').last().data('precision'));
|
||||
}
|
||||
var formatted = _.str.sprintf('%.' + precision + 'f', price).split('.');
|
||||
formatted[0] = utils.insert_thousand_seps(formatted[0]);
|
||||
return formatted.join(l10n.decimal_point);
|
||||
},
|
||||
/**
|
||||
* Returns a throttled `_getCombinationInfo` with a leading and a trailing
|
||||
* call, which is memoized per `uniqueId`, and for which previous results
|
||||
* are dropped.
|
||||
*
|
||||
* The uniqueId is needed because on the configurator modal there might be
|
||||
* multiple elements triggering the rpc at the same time, and we need each
|
||||
* individual product rpc to be executed, but only once per individual
|
||||
* product.
|
||||
*
|
||||
* The leading execution is to keep good reactivity on the first call, for
|
||||
* a better user experience. The trailing is because ultimately only the
|
||||
* information about the last selected combination is useful. All
|
||||
* intermediary rpc can be ignored and are therefore best not done at all.
|
||||
*
|
||||
* The DropMisordered is to make sure slower rpc are ignored if the result
|
||||
* of a newer rpc has already been received.
|
||||
*
|
||||
* @private
|
||||
* @param {string} uniqueId
|
||||
* @returns {function}
|
||||
*/
|
||||
_throttledGetCombinationInfo: _.memoize(function (uniqueId) {
|
||||
var dropMisordered = new concurrency.DropMisordered();
|
||||
var _getCombinationInfo = _.throttle(this._getCombinationInfo.bind(this), 500);
|
||||
return function (ev, params) {
|
||||
return dropMisordered.add(_getCombinationInfo(ev, params));
|
||||
};
|
||||
}),
|
||||
/**
|
||||
* Toggles the disabled class depending on the $parent element
|
||||
* and the possibility of the current combination.
|
||||
*
|
||||
* @private
|
||||
* @param {$.Element} $parent
|
||||
* @param {boolean} isCombinationPossible
|
||||
*/
|
||||
_toggleDisable: function ($parent, isCombinationPossible) {
|
||||
$parent.toggleClass('css_not_available', !isCombinationPossible);
|
||||
if ($parent.hasClass('in_cart')) {
|
||||
const primaryButton = $parent.parents('.modal-content').find('.modal-footer .btn-primary');
|
||||
primaryButton.prop('disabled', !isCombinationPossible);
|
||||
primaryButton.toggleClass('disabled', !isCombinationPossible);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Updates the product image.
|
||||
* This will use the productId if available or will fallback to the productTemplateId.
|
||||
*
|
||||
* @private
|
||||
* @param {$.Element} $productContainer
|
||||
* @param {boolean} displayImage will hide the image if true. It will use the 'invisible' class
|
||||
* instead of d-none to prevent layout change
|
||||
* @param {integer} product_id
|
||||
* @param {integer} productTemplateId
|
||||
*/
|
||||
_updateProductImage: function ($productContainer, displayImage, productId, productTemplateId) {
|
||||
var model = productId ? 'product.product' : 'product.template';
|
||||
var modelId = productId || productTemplateId;
|
||||
var imageUrl = '/web/image/{0}/{1}/' + (this._productImageField ? this._productImageField : 'image_1024');
|
||||
var imageSrc = imageUrl
|
||||
.replace("{0}", model)
|
||||
.replace("{1}", modelId);
|
||||
|
||||
var imagesSelectors = [
|
||||
'span[data-oe-model^="product."][data-oe-type="image"] img:first',
|
||||
'img.product_detail_img',
|
||||
'span.variant_image img',
|
||||
'img.variant_image',
|
||||
];
|
||||
|
||||
var $img = $productContainer.find(imagesSelectors.join(', '));
|
||||
|
||||
if (displayImage) {
|
||||
$img.removeClass('invisible').attr('src', imageSrc);
|
||||
} else {
|
||||
$img.addClass('invisible');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Highlight selected color
|
||||
*
|
||||
* @private
|
||||
* @param {MouseEvent} ev
|
||||
*/
|
||||
_onChangeColorAttribute: function (ev) {
|
||||
var $parent = $(ev.target).closest('.js_product');
|
||||
$parent.find('.css_attribute_color')
|
||||
.removeClass("active")
|
||||
.filter(':has(input:checked)')
|
||||
.addClass("active");
|
||||
},
|
||||
|
||||
_onChangePillsAttribute: function (ev) {
|
||||
const radio = ev.target.closest('.o_variant_pills').querySelector("input");
|
||||
radio.click(); // Trigger onChangeVariant.
|
||||
var $parent = $(ev.target).closest('.js_product');
|
||||
$parent.find('.o_variant_pills')
|
||||
.removeClass("active")
|
||||
.filter(':has(input:checked)')
|
||||
.addClass("active");
|
||||
},
|
||||
|
||||
/**
|
||||
* Return true if the current object has been destroyed.
|
||||
* This function has been added as a fix to know if the result of a rpc
|
||||
* should be handled. Indeed, "this._rpc()" can not be used as it is not
|
||||
* supported by some elements that use this mixin.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
_shouldIgnoreRpcResult() {
|
||||
return (typeof this.isDestroyed === "function" && this.isDestroyed());
|
||||
},
|
||||
|
||||
/**
|
||||
* Extension point for website_sale
|
||||
*
|
||||
* @private
|
||||
* @param {string} uri The uri to adapt
|
||||
*/
|
||||
_getUri: function (uri) {
|
||||
return uri;
|
||||
}
|
||||
};
|
||||
|
||||
return VariantMixin;
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
.css_attribute_color {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border: 5px solid $input-border-color;
|
||||
border-radius: 50%;
|
||||
text-align: center;
|
||||
transition: $input-transition;
|
||||
|
||||
@include o-field-pointer();
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
display: block;
|
||||
@include o-position-absolute(-3px, -3px, -3px, -3px);
|
||||
border: 4px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 3px rgba(black, 0.3);
|
||||
}
|
||||
|
||||
input {
|
||||
margin: 8px;
|
||||
height: 13px;
|
||||
width: 13px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border: 5px solid map-get($theme-colors, 'primary');
|
||||
}
|
||||
|
||||
&.custom_value {
|
||||
background-image: linear-gradient(to bottom right, #FF0000, #FFF200, #1E9600);
|
||||
}
|
||||
|
||||
&.transparent {
|
||||
background-image: url(/web/static/img/transparent.png);
|
||||
}
|
||||
}
|
||||
|
||||
.css_not_available_msg {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.css_not_available.js_product {
|
||||
.css_quantity {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.css_not_available_msg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.availability_messages {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.js_add,
|
||||
.oe_price,
|
||||
.oe_default_price,
|
||||
.oe_optional {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.css_quantity {
|
||||
width: initial; // We don't want the quantity form to be full-width
|
||||
|
||||
.btn, input {
|
||||
border-color: $input-border-color;
|
||||
}
|
||||
|
||||
input {
|
||||
// Needs !important because themes customize btns' padding direclty
|
||||
// rather than change '$input-btn-padding-X' (shared with inputs).
|
||||
height: auto !important;
|
||||
max-width: 5ch;
|
||||
}
|
||||
}
|
||||
|
||||
option.css_not_available {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
select.form-select.css_attribute_select {
|
||||
background-image: str-replace(url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='175' height='100' fill='#{theme-color('primary')}'><polygon points='0,0 100,0 50,50'/></svg>"), "#", "%23") ;
|
||||
background-size: 20px;
|
||||
background-position: 100% 65%;
|
||||
background-repeat: no-repeat;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
label, .o_variant_pills {
|
||||
&.css_not_available {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
label.css_attribute_color.css_not_available {
|
||||
opacity: 1;
|
||||
|
||||
&:after {
|
||||
content: "";
|
||||
@include o-position-absolute(-5px, -5px, -5px, -5px);
|
||||
border: 2px solid map-get($theme-colors, 'danger');
|
||||
border-radius: 50%;
|
||||
background: str-replace(url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='39' height='39'><line y2='0' x2='39' y1='39' x1='0' style='stroke:#{map-get($theme-colors, 'danger')};stroke-width:2'/><line y2='1' x2='40' y1='40' x1='1' style='stroke:rgb(255,255,255);stroke-width:1'/></svg>"), "#", "%23") ;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
}
|
||||
|
||||
.variant_attribute {
|
||||
padding-bottom: 1rem;
|
||||
|
||||
.attribute_name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.9rem;
|
||||
text-transform: uppercase;
|
||||
padding-bottom: 0.5rem;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
margin-left: $spacer;
|
||||
flex-grow: 1;
|
||||
border-bottom: 1px solid map-get($grays, '400');
|
||||
}
|
||||
}
|
||||
|
||||
.radio_input_value {
|
||||
font-weight: 400;
|
||||
|
||||
&:not(.o_variant_pills_input_value) {
|
||||
margin-right: $spacer;
|
||||
|
||||
&, > span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
&.o_variant_pills_input_value {
|
||||
.badge {
|
||||
color: map-get($grays, '600');
|
||||
background: white;
|
||||
border: 1px solid map-get($theme-colors, 'primary');
|
||||
|
||||
&, > span {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.sign_badge_price_extra {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.variant_custom_value {
|
||||
margin-bottom: 0.7rem;
|
||||
|
||||
&.custom_value_radio {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
ul.list-inline {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.o_variant_pills {
|
||||
padding: $spacer/2 $spacer;
|
||||
margin-right: 0.2rem;
|
||||
border: none;
|
||||
cursor: default !important;
|
||||
|
||||
&.btn.active {
|
||||
background-color: map-get($theme-colors, 'primary');
|
||||
}
|
||||
&:not(.active) {
|
||||
color: map-get($grays, '600');
|
||||
background-color: map-get($grays, '200');
|
||||
}
|
||||
|
||||
input {
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
opacity: 0;
|
||||
position: absolute !important;
|
||||
}
|
||||
}
|
||||
|
||||
.radio_input_value, select, label {
|
||||
.badge {
|
||||
margin-left: 3px;
|
||||
padding-left: 3px;
|
||||
}
|
||||
|
||||
.sign_badge_price_extra {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: map-get($theme-colors, 'primary');
|
||||
background: white;
|
||||
line-height: 1rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_product_configurator {
|
||||
.product_detail_img {
|
||||
max-height: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
.table-striped tbody tr:nth-of-type(odd) {
|
||||
.o_select_options {
|
||||
background-color: rgba(0, 0, 0, 0.025);
|
||||
}
|
||||
|
||||
.o_total_row {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.modal.o_technical_modal .oe_advanced_configurator_modal .btn.js_add_cart_json {
|
||||
padding: 0.075rem 0.75rem;
|
||||
}
|
||||
|
||||
.js_product {
|
||||
|
||||
.td-product_name {
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.td-product_name {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.td-img {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.td-qty {
|
||||
width: 200px;
|
||||
a.input-group-addon {
|
||||
background-color: transparent;
|
||||
border: 0px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
.td-action {
|
||||
width: 30px;
|
||||
}
|
||||
|
||||
.td-price,
|
||||
.td-price-total {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
.td-img,
|
||||
.td-price-total {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.td-qty {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.td-price {
|
||||
width: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 476px) {
|
||||
.td-qty {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
#modal_optional_products table thead,
|
||||
.oe_cart table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#modal_optional_products table td.td-img,
|
||||
.oe_cart table td.td-img {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_total_row {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.oe_striked_price {
|
||||
text-decoration: line-through;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
.o_onboarding_order_confirmation {
|
||||
& span.o_onboarding_order_confirmation_help img {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom:0;
|
||||
}
|
||||
& span.o_onboarding_order_confirmation_help:hover img {
|
||||
display: block
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
/* ---- My Orders page ---- */
|
||||
|
||||
.orders_vertical_align {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.orders_label_text_align {
|
||||
vertical-align: 15%;
|
||||
}
|
||||
|
||||
/* ---- Order page ---- */
|
||||
|
||||
.sale_tbody .o_line_note {
|
||||
word-break: break-word;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.sale_tbody input.js_quantity {
|
||||
min-width: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sale_tbody div.input-group.w-50.pull-right {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.o_portal .sale_tbody .js_quantity_container {
|
||||
|
||||
.js_quantity {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.input-group-text {
|
||||
padding: 0.2rem 0.4rem;
|
||||
}
|
||||
|
||||
@include media-breakpoint-down(md) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.sale_tbody .o_line_note {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="sale.ProductDiscountField" t-inherit="web.FloatField" t-inherit-mode="primary" owl="1">
|
||||
<xpath expr="//input" position="attributes">
|
||||
<attribute name="t-on-change">onChange</attribute>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="sale.SaleProductField" t-inherit="web.Many2OneField" t-inherit-mode="primary" owl="1">
|
||||
<!-- Make the product label clickable (to open its form view) when the user cannot
|
||||
access it through the external button (because the product/line is readonly) -->
|
||||
<xpath expr="//t[@t-if='!props.canOpen']" position="attributes">
|
||||
<attribute name="t-if">
|
||||
!isProductClickable
|
||||
</attribute>
|
||||
</xpath>
|
||||
<!-- Show configuration button for custom lines/products -->
|
||||
<xpath expr="//t[@t-if='hasExternalButton']" position="before">
|
||||
<t t-if="hasConfigurationButton">
|
||||
<button
|
||||
type="button"
|
||||
t-att-class="configurationButtonIcon"
|
||||
tabindex="-1"
|
||||
draggable="false"
|
||||
t-att-aria-label="configurationButtonHelp"
|
||||
t-att-data-tooltip="configurationButtonHelp"
|
||||
t-on-click="onEditConfiguration"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="sale.SaleProgressBarField" owl="1">
|
||||
<t t-if="state.isInvoicingTargetDefined">
|
||||
<t t-call="web.ProgressBarField"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<!-- TODO is this class needed here ? -->
|
||||
<a t-on-click.prevent="defineInvoicingTarget" href="#" class="sale_progressbar_form_link">
|
||||
Click to define an invoicing target
|
||||
</a>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import {
|
||||
getFixture,
|
||||
patchWithCleanup,
|
||||
addRow,
|
||||
editInput,
|
||||
triggerHotkey,
|
||||
nextTick
|
||||
} from "@web/../tests/helpers/utils";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
|
||||
let serverData;
|
||||
let target;
|
||||
|
||||
QUnit.module("Fields", (hooks) => {
|
||||
hooks.beforeEach(() => {
|
||||
target = getFixture();
|
||||
serverData = {
|
||||
models: {
|
||||
'sale.order': {
|
||||
fields: {
|
||||
display_name: { string: "Displayed name", type: "char" },
|
||||
order_line: {
|
||||
string: "order lines",
|
||||
type: "one2many",
|
||||
relation: "sale.order.line",
|
||||
relation_field: "order_id",
|
||||
},
|
||||
},
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
display_name: "first record",
|
||||
order_line: [],
|
||||
},
|
||||
],
|
||||
onchanges: {},
|
||||
},
|
||||
'sale.order.line': {
|
||||
fields: {
|
||||
product_template_id: {
|
||||
string: "Product",
|
||||
type: "many2one",
|
||||
relation: "product.template",
|
||||
},
|
||||
},
|
||||
records: [
|
||||
],
|
||||
},
|
||||
'product.template': {
|
||||
fields: {
|
||||
display_name: { string: "Partner Type", type: "char" },
|
||||
name: { string: "Partner Type", type: "char" },
|
||||
},
|
||||
records: [
|
||||
{ id: 12, display_name: "desk" },
|
||||
],
|
||||
methods: {
|
||||
get_single_product_variant() {
|
||||
return Promise.resolve({product_id: 12});
|
||||
}
|
||||
}
|
||||
},
|
||||
user: {
|
||||
fields: {
|
||||
name: { string: "Name", type: "char" },
|
||||
partner_ids: {
|
||||
string: "one2many partners field",
|
||||
type: "one2many",
|
||||
relation: "partner",
|
||||
relation_field: "user_id",
|
||||
},
|
||||
},
|
||||
records: [
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
setupViewRegistries();
|
||||
|
||||
patchWithCleanup(browser, {
|
||||
setTimeout: (fn) => fn(),
|
||||
});
|
||||
});
|
||||
|
||||
QUnit.module("Sale product field");
|
||||
|
||||
QUnit.test("pressing tab with incomplete text will create a product", async function (assert) {
|
||||
|
||||
await makeView({
|
||||
type: "form",
|
||||
resModel: "sale.order",
|
||||
serverData,
|
||||
arch: `
|
||||
<form>
|
||||
<sheet>
|
||||
<field name="order_line">
|
||||
<tree editable="bottom" >
|
||||
<field name="product_template_id" widget="sol_product_many2one" />
|
||||
</tree>
|
||||
</field>
|
||||
</sheet>
|
||||
</form>`,
|
||||
mockRPC(route, args) {
|
||||
assert.step(args.method);
|
||||
}
|
||||
});
|
||||
|
||||
// add a line and enter new product name
|
||||
await addRow(target, ".o_field_x2many_list");
|
||||
await editInput(target, "[name='product_template_id'] input", "new product");
|
||||
await triggerHotkey("tab");
|
||||
await nextTick();
|
||||
assert.verifySteps([
|
||||
"get_views",
|
||||
"onchange",
|
||||
"onchange",
|
||||
"name_search",
|
||||
"name_create",
|
||||
"get_single_product_variant",
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { click, getFixture } from "@web/../tests/helpers/utils";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
const serviceRegistry = registry.category("services");
|
||||
|
||||
let serverData;
|
||||
let target;
|
||||
|
||||
QUnit.module("Sales Team Dashboard", {
|
||||
beforeEach() {
|
||||
target = getFixture();
|
||||
serverData = {
|
||||
models: {
|
||||
"crm.team": {
|
||||
fields: {
|
||||
foo: { string: "Foo", type: "char" },
|
||||
invoiced: { string: "Invoiced", type: "integer" },
|
||||
invoiced_target: { string: "Invoiced_target", type: "integer" },
|
||||
},
|
||||
records: [{ id: 1, foo: "yop", invoiced: 0, invoiced_target: 0 }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
setupViewRegistries();
|
||||
},
|
||||
});
|
||||
|
||||
QUnit.test("edit target with several o_kanban_primary_bottom divs", async (assert) => {
|
||||
assert.expect(4);
|
||||
|
||||
const fakeActionService = {
|
||||
start: () => ({
|
||||
async doAction(action) {
|
||||
assert.deepEqual(
|
||||
action,
|
||||
{
|
||||
res_model: "crm.team",
|
||||
target: "current",
|
||||
type: "ir.actions.act_window",
|
||||
method: "get_formview_action",
|
||||
},
|
||||
"should trigger do_action with the correct args"
|
||||
);
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
};
|
||||
serviceRegistry.add("action", fakeActionService, { force: true });
|
||||
|
||||
await makeView({
|
||||
serverData,
|
||||
type: "kanban",
|
||||
resModel: "crm.team",
|
||||
arch: /* xml */`
|
||||
<kanban>
|
||||
<field name="invoiced_target"/>
|
||||
<templates>
|
||||
<div t-name="kanban-box" class="container o_kanban_card_content">
|
||||
<field name="invoiced" widget="sales_team_progressbar" options="{'current_value': 'invoiced', 'max_value': 'invoiced_target', 'editable': true, 'edit_max_value': true}"/>
|
||||
<div class="col-12 o_kanban_primary_bottom"/>
|
||||
<div class="col-12 o_kanban_primary_bottom bottom_block"/>
|
||||
</div>
|
||||
</templates>
|
||||
</kanban>`,
|
||||
resId: 1,
|
||||
async mockRPC(route, { method, model }) {
|
||||
if (route === "/web/dataset/call_kw/crm.team/get_formview_action") {
|
||||
return {
|
||||
method,
|
||||
res_model: model,
|
||||
target: "current",
|
||||
type: "ir.actions.act_window",
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_field_sales_team_progressbar:contains(Click to define an invoicing target)"
|
||||
);
|
||||
assert.containsN(target, ".o_kanban_primary_bottom", 2);
|
||||
assert.containsNone(target, ".o_progressbar input");
|
||||
|
||||
await click(target, ".sale_progressbar_form_link"); // should trigger a do_action
|
||||
});
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
odoo.define('sale.tour_sale_signature', function (require) {
|
||||
'use strict';
|
||||
|
||||
var tour = require('web_tour.tour');
|
||||
|
||||
// This tour relies on data created on the Python test.
|
||||
tour.register('sale_signature', {
|
||||
test: true,
|
||||
url: '/my/quotes',
|
||||
},
|
||||
[
|
||||
{
|
||||
content: "open the test SO",
|
||||
trigger: 'a:containsExact("test SO")',
|
||||
},
|
||||
{
|
||||
content: "click sign",
|
||||
trigger: 'a:contains("Sign")',
|
||||
},
|
||||
{
|
||||
content: "check submit is enabled",
|
||||
trigger: '.o_portal_sign_submit:enabled',
|
||||
run: function () {},
|
||||
},
|
||||
{
|
||||
content: "click select style",
|
||||
trigger: '.o_web_sign_auto_select_style a',
|
||||
},
|
||||
{
|
||||
content: "click style 4",
|
||||
trigger: '.o_web_sign_auto_font_selection a:eq(3)',
|
||||
},
|
||||
{
|
||||
content: "click submit",
|
||||
trigger: '.o_portal_sign_submit:enabled',
|
||||
},
|
||||
{
|
||||
content: "check it's confirmed",
|
||||
trigger: '#quote_content:contains("Thank You")',
|
||||
}, {
|
||||
trigger: '#quote_content',
|
||||
run: function () {
|
||||
window.location.href = window.location.origin + '/web';
|
||||
}, // Avoid race condition at the end of the tour by returning to the home page.
|
||||
},
|
||||
{
|
||||
trigger: 'nav',
|
||||
run: function() {},
|
||||
}
|
||||
]);
|
||||
});
|
||||