19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:12 +01:00
parent 79f83631d5
commit 73afc09215
6267 changed files with 1534193 additions and 1130106 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 861 B

Before After
Before After

View file

@ -1 +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>
<svg width="50" height="50" viewBox="0 0 50 50" xmlns="http://www.w3.org/2000/svg"><path d="M4 25a4 4 0 0 1 4-4h7v25H4V25Z" fill="#985184"/><path d="M26 8c0-2.21 1.876-4 4.19-4H46v38c0 2.21-1.876 4-4.19 4H26V8Z" fill="#FBB945"/><path d="M15 17.067C15 14.821 16.876 13 19.19 13H35v28.933C35 44.179 33.124 46 30.81 46H15V17.067Z" fill="#FC868B"/><path d="M26 46h4.81c2.314 0 4.19-1.821 4.19-4.067V13h-9v33Z" fill="#F86126"/><path d="m15 46 4.995-.002A4.005 4.005 0 0 0 24 41.995V21h-9v25Z" fill="#962B48"/></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 511 B

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

View file

@ -0,0 +1,314 @@
import { getSectionRecords } from '@account/components/section_and_note_fields_backend/section_and_note_fields_backend';
import { SaleOrderLineListRenderer } from '@sale/js/sale_order_line_field/sale_order_line_field';
import { makeContext } from '@web/core/context';
import { x2ManyCommands } from '@web/core/orm_service';
import { patch } from '@web/core/utils/patch';
patch(SaleOrderLineListRenderer.prototype, {
setup() {
super.setup();
this.copyFields.push('is_optional');
},
/**
* Disable "Hide Composition" and "Hide Prices" buttons for optional sections and their
* subsections.
*/
get disableCompositionButton() {
return (
super.disableCompositionButton
|| this.shouldCollapse(this.record, 'is_optional', true)
);
},
get disablePricesButton() {
return (
super.disablePricesButton
|| this.shouldCollapse(this.record, 'is_optional', true)
);
},
/**
* Disable "Set Optional" button if
* - Parent section is optional
* - Parent section hides prices or composition
* - Section itself hides prices or composition
*/
get disableOptionalButton() {
return (
this.shouldCollapse(this.record, 'is_optional')
|| this.shouldCollapse(this.record, 'collapse_prices', true)
|| this.shouldCollapse(this.record, 'collapse_composition', true)
);
},
get isCurrentSectionOptional() {
if (this.props.list.records.length === 0) return false;
return this.shouldCollapse(
this.props.list.records[this.props.list.records.length - 1],
'is_optional',
true
);
},
/**
* Override to set the default `product_uom_qty` to 0 for new lines created under an optional
* section.
*/
add(params){
params.context = this.getCreateContext(params);
super.add(params);
},
getCreateContext(params) {
const evaluatedContext = makeContext([params.context]);
// A falsy context indicates a product line (no `display_type` specified)
if(!evaluatedContext[`default_display_type`] && this.isCurrentSectionOptional) {
return { ...evaluatedContext, default_product_uom_qty: 0 };
}
return params.context;
},
/**
* Override to set the default `product_uom_qty` to 0 for new lines inserted by optional
* sections from dropdown.
*/
getInsertLineContext(record, addSubSection) {
if (this.shouldCollapse(record, 'is_optional', true) && !addSubSection) {
return {
...super.getInsertLineContext(record, addSubSection),
default_product_uom_qty: 0
};
}
return super.getInsertLineContext(record, addSubSection);
},
getRowClass(record) {
let rowClasses = super.getRowClass(record);
if (this.shouldCollapse(record, 'is_optional', true)) {
rowClasses += ' text-primary';
}
return rowClasses;
},
/**
* @override
* This override resets optional state of subsections when their parent sections is collapsed
*/
async toggleCollapse(record, fieldName) {
await super.toggleCollapse(record, fieldName);
if (this.isTopSection(record) && record.data[fieldName]) {
const commands = [];
for (const sectionRecord of getSectionRecords(this.props.list, record)) {
if (this.isSubSection(sectionRecord)) {
commands.push(
x2ManyCommands.update(sectionRecord.resId || sectionRecord._virtualId, {
is_optional: false,
})
)
}
}
if (commands.length) {
await this.props.list.applyCommands(commands, { sort: true });
}
}
},
/**
* Toggles optional state on a section:
* - Product lines qty = 0 when set optional, reset to 1 when unset.
* - Subsections force hide composition/prices to false.
*/
async toggleIsOptional(record) {
const setOptional = !record.data.is_optional;
const commands = [(x2ManyCommands.update(record.resId || record._virtualId, {
is_optional: setOptional,
}))];
const proms = [];
for (const sectionRecord of getSectionRecords(this.props.list, record)) {
let changes = {};
if (!sectionRecord.data.display_type) {
if (setOptional) {
changes = { product_uom_qty: 0, price_total: 0, price_subtotal: 0 }
} else {
proms.push(sectionRecord._update({ product_uom_qty: sectionRecord.data.product_uom_qty || 1 }));
}
} else if (this.isSubSection(sectionRecord)) {
changes = setOptional && {
collapse_composition: false,
collapse_prices: false,
};
}
if (Object.keys(changes).length) {
commands.push(
x2ManyCommands.update(
sectionRecord.resId || sectionRecord._virtualId,
changes
)
);
}
}
await this.props.list.applyCommands(commands, { sort: true });
await Promise.all(proms);
},
/**
* @override
* Handles product line quantity adjustments when a record is dragged and dropped.
*
* Behavior:
* - If a product line is moved under an optional section, its quantity is set to `0`.
* - If a product line is dragged out of an optional section and had `0` quantity,
* its quantity is reset to `1`.
* - Non-product lines (`display_type` set) are ignored.
*/
async sortDrop(dataRowId, dataGroupId, { element, previous }) {
const record = this.props.list.records.find(r => r.id === dataRowId);
const recordMap = this._getRecordsToRecompute(record, previous ? previous.dataset.id : null);
await super.sortDrop(dataRowId, dataGroupId, { element, previous });
await this._handleQuantityAdjustment(recordMap);
},
/**
* Builds a map of records whose optional state needs to be recomputed
* after a record is moved within the list.
*
* The maps keys are record IDs, and values represent their current
* `is_optional` collapse state as determined by `shouldCollapse()`.
*
* @param {Object} record - The record being moved.
* @param {number|string} targetId - The ID of the record that serves as the new drop target.
* @returns {Map<number|string, boolean>} A map of record IDs to their recomputed optional states.
*/
_getRecordsToRecompute(record, targetId) {
const optionalStateMap = new Map();
if (this.isSection(record)) { // If a section or subsection is moved
let currentIndex = this.props.list.records.indexOf(record);
let targetIndex = this.props.list.records.findIndex(r => r.id === targetId);
if (currentIndex > targetIndex) {
//When moving up, recompute:
// 1. All records under the moved section.
// 2. All records between the new and old positions.
for (let i = currentIndex; i > targetIndex; i--) {
if (!this.props.list.records[i].data.display_type) {
optionalStateMap.set(
this.props.list.records[i].id,
this.shouldCollapse(this.props.list.records[i], 'is_optional')
);
}
}
for (const sectionRecord of getSectionRecords(this.props.list, record)) {
if (!sectionRecord.data.display_type) {
optionalStateMap.set(sectionRecord.id, this.shouldCollapse(sectionRecord, 'is_optional'));
}
}
} else {
//When moving down, recompute:
// 1. All records under sections between the old and new positions.
// 2. All records between the old and new positions (skipping overlaps).
for (let i = currentIndex; i <= targetIndex; i++) {
if (this.isSection(this.props.list.records[i])) {
for (const sectionRecord of getSectionRecords(this.props.list, this.props.list.records[i])) {
if (
!optionalStateMap.has(sectionRecord.id)
&& !sectionRecord.data.display_type
) {
optionalStateMap.set(
sectionRecord.id,
this.shouldCollapse(sectionRecord, 'is_optional')
);
}
}
}
// we must skip overlapping records
if (
!optionalStateMap.has(this.props.list.records[i].id)
&& !this.props.list.records[i].data.display_type
) {
optionalStateMap.set(
this.props.list.records[i].id,
this.shouldCollapse(this.props.list.records[i], 'is_optional')
);
}
}
}
} else if (!record.data.display_type) { // If a regular record is moved compute its own optional state
optionalStateMap.set(record.id, this.shouldCollapse(record, 'is_optional'));
}
return optionalStateMap;
},
async _handleQuantityAdjustment(recordMap) {
const commands = [];
for (const [recordId, wasOptional] of recordMap.entries()) {
const record = this.props.list.records.find(r => r.id === recordId);
const isOptional = this.shouldCollapse(record, 'is_optional');
if (wasOptional && !isOptional && !record.data.product_uom_qty) {
commands.push(x2ManyCommands.update(
record.resId || record._virtualId, { product_uom_qty: 1 }
));
} else if (!wasOptional && isOptional) {
commands.push(x2ManyCommands.update(
record.resId || record._virtualId, { product_uom_qty: 0 }
));
}
}
await this.props.list.applyCommands(commands, { sort: true });
},
/**
* @override
* Reset fields when a subsection is moved under an optional section,
* since optional sections cannot contain hidden subsections or hidden prices.
*/
resetOnResequence(record, parentSection) {
return (
super.resetOnResequence(record, parentSection)
|| (
this.isSubSection(record)
&& parentSection?.data.is_optional
&& (
record.data.collapse_composition
|| record.data.collapse_prices
|| record.data.is_optional
)
)
);
},
fieldsToReset() {
return { ...super.fieldsToReset(), is_optional: false };
},
async moveCombo(record, direction) {
const wasOptional = this.shouldCollapse(record, 'is_optional');
await super.moveCombo(record, direction);
const isOptional = this.shouldCollapse(record, 'is_optional');
if (wasOptional && !isOptional && !record.data.product_uom_qty) {
await record.update({ product_uom_qty: 1 });
} else if (!wasOptional && isOptional) {
await record.update({ product_uom_qty: 0 });
}
}
});

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t t-inherit="sale.ListRenderer.RecordRow" t-inherit-mode="extension">
<t t-name="composition_button" position="after">
<DropdownItem
onSelected="() => this.toggleIsOptional(record)"
attrs="{ 'class': disableOptionalButton ? 'disabled' : '' }"
>
<i class="me-1 fa fa-fw fa-list"/>
<span t-if="record.data.is_optional">Unset Optional</span>
<span t-else="">Set Optional</span>
</DropdownItem>
</t>
</t>
</templates>

View file

@ -0,0 +1,228 @@
import {
SectionAndNoteFieldOne2Many,
sectionAndNoteFieldOne2Many,
SectionAndNoteListRenderer,
getSectionRecords,
} from '@account/components/section_and_note_fields_backend/section_and_note_fields_backend';
import { makeContext } from '@web/core/context';
import { x2ManyCommands } from '@web/core/orm_service';
import { registry } from '@web/core/registry';
export class SaleOrderTemplateLineListRenderer extends SectionAndNoteListRenderer {
static recordRowTemplate = 'sale_management.ListRenderer.RecordRow';
setup() {
super.setup();
this.copyFields.push('is_optional');
}
get disableOptionalButton() {
return this.shouldCollapse(this.record, 'is_optional');
}
get isCurrentSectionOptional() {
if (this.props.list.records.length === 0) return false;
return this.shouldCollapse(
this.props.list.records[this.props.list.records.length - 1],
'is_optional',
true
);
}
/**
* Override to set the default `product_uom_qty` to 0 for new lines created under an optional
* section.
*/
add(params){
params.context = this.getCreateContext(params);
super.add(params);
}
getCreateContext(params) {
const evaluatedContext = makeContext([params.context]);
// A falsy context indicates a product line (no `display_type` specified)
if(!evaluatedContext[`default_display_type`] && this.isCurrentSectionOptional) {
return { ...evaluatedContext, default_product_uom_qty: 0 };
}
return params.context;
}
/**
* Override to set the default `product_uom_qty` to 0 for new lines inserted by optional
* sections from dropdown.
*/
getInsertLineContext(record, addSubSection) {
if (this.shouldCollapse(record, 'is_optional', true) && !addSubSection) {
return {
...super.getInsertLineContext(record, addSubSection),
default_product_uom_qty: 0
};
}
return super.getInsertLineContext(record, addSubSection);
}
getRowClass(record) {
let rowClasses = super.getRowClass(record);
if (this.shouldCollapse(record, 'is_optional', true)) {
rowClasses += ' text-primary';
}
return rowClasses;
}
async toggleIsOptional(record) {
const setOptional = !record.data.is_optional;
const commands = [(x2ManyCommands.update(record.resId || record._virtualId, {
is_optional: setOptional,
}))];
for (const sectionRecord of getSectionRecords(this.props.list, record)) {
let changes = {};
if (!sectionRecord.data.display_type) {
changes = setOptional
? { product_uom_qty: 0 }
: { product_uom_qty: sectionRecord.data.product_uom_qty || 1 };
}
if (Object.keys(changes).length) {
commands.push(
x2ManyCommands.update(
sectionRecord.resId || sectionRecord._virtualId,
changes
)
);
}
}
await this.props.list.applyCommands(commands, { sort: true });
}
/**
* @override
* Handles product line quantity adjustments when a record is dragged and dropped.
*
* Behavior:
* - If a product line is moved under an optional section, its quantity is set to `0`.
* - If a product line is dragged out of an optional section and had `0` quantity,
* its quantity is reset to `1`.
* - Non-product lines (`display_type` set) are ignored.
*
*/
async sortDrop(dataRowId, dataGroupId, { element, previous }) {
const record = this.props.list.records.find(r => r.id === dataRowId);
const recordMap = this._getRecordsToRecompute(record, previous ? previous.dataset.id : null);
await super.sortDrop(dataRowId, dataGroupId, { element, previous });
await this._handleQuantityAdjustment(recordMap);
}
/**
* Builds a map of records whose optional state needs to be recomputed
* after a record is moved within the list.
*
* The maps keys are record IDs, and values represent their current
* `is_optional` collapse state as determined by `shouldCollapse()`.
*
* @param {Object} record - The record being moved.
* @param {number|string} targetId - The ID of the record that serves as the new drop target.
* @returns {Map<number|string, boolean>} A map of record IDs to their recomputed optional states.
*/
_getRecordsToRecompute(record, targetId) {
const optionalStateMap = new Map();
if (this.isSection(record)) { // If a section or subsection is moved
let currentIndex = this.props.list.records.indexOf(record);
let targetIndex = this.props.list.records.findIndex(r => r.id === targetId);
if (currentIndex > targetIndex) {
//When moving up, recompute:
// 1. All records under the moved section.
// 2. All records between the new and old positions.
for (let i = currentIndex; i > targetIndex; i--) {
if (!this.props.list.records[i].data.display_type) {
optionalStateMap.set(
this.props.list.records[i].id,
this.shouldCollapse(this.props.list.records[i], 'is_optional')
);
}
}
for (const sectionRecord of getSectionRecords(this.props.list, record)) {
if (!sectionRecord.data.display_type) {
optionalStateMap.set(sectionRecord.id, this.shouldCollapse(sectionRecord, 'is_optional'));
}
}
} else {
//When moving down, recompute:
// 1. All records under sections between the old and new positions.
// 2. All records between the old and new positions (skipping overlaps).
for (let i = currentIndex; i <= targetIndex; i++) {
if (this.isSection(this.props.list.records[i])) {
for (const sectionRecord of getSectionRecords(this.props.list, this.props.list.records[i])) {
if (
!optionalStateMap.has(sectionRecord.id)
&& !sectionRecord.data.display_type
) {
optionalStateMap.set(
sectionRecord.id,
this.shouldCollapse(sectionRecord, 'is_optional')
);
}
}
}
// we must skip overlapping records
if (
!optionalStateMap.has(this.props.list.records[i].id)
&& !this.props.list.records[i].data.display_type
) {
optionalStateMap.set(
this.props.list.records[i].id,
this.shouldCollapse(this.props.list.records[i], 'is_optional')
);
}
}
}
} else if (!record.data.display_type) { // If a regular record is moved compute its own optional state
optionalStateMap.set(record.id, this.shouldCollapse(record, 'is_optional'));
}
return optionalStateMap;
}
async _handleQuantityAdjustment(recordMap) {
const commands = [];
for (const [recordId, wasOptional] of recordMap.entries()) {
const record = this.props.list.records.find(r => r.id === recordId);
const isOptional = this.shouldCollapse(record, 'is_optional');
if (wasOptional && !isOptional && !record.data.product_uom_qty) {
commands.push(x2ManyCommands.update(
record.resId || record._virtualId, { product_uom_qty: 1 }
));
} else if (!wasOptional && isOptional) {
commands.push(x2ManyCommands.update(
record.resId || record._virtualId, { product_uom_qty: 0 }
));
}
}
await this.props.list.applyCommands(commands, { sort: true });
}
}
export class SaleOrderTemplateLineOne2Many extends SectionAndNoteFieldOne2Many {
static components = {
...super.components,
ListRenderer: SaleOrderTemplateLineListRenderer,
};
}
export const saleOrderTemplateLineOne2Many = {
...sectionAndNoteFieldOne2Many,
component: SaleOrderTemplateLineOne2Many,
};
registry.category('fields').add('so_template_line_o2m', saleOrderTemplateLineOne2Many);

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<templates>
<t
t-name="sale_management.ListRenderer.RecordRow"
t-inherit="account.SectionAndNoteListRenderer.RecordRow"
t-inherit-mode="primary"
>
<t t-name="composition_button" position="after">
<DropdownItem
onSelected="() => this.toggleIsOptional(record)"
attrs="{ 'class': disableOptionalButton ? 'disabled' : '' }"
>
<i class="me-1 fa fa-fw fa-list"/>
<span t-if="record.data.is_optional">Unset Optional</span>
<span t-else="">Set Optional</span>
</DropdownItem>
</t>
</t>
</templates>

View file

@ -0,0 +1,24 @@
import { SaleOrderLineProductField } from '@sale/js/sale_product_field';
import { patch } from '@web/core/utils/patch';
patch(SaleOrderLineProductField.prototype, {
_getAdditionalDialogProps() {
const props = super._getAdditionalDialogProps();
const isOptionalLine = this.env.shouldCollapse(this.props.record, 'is_optional');
props.options = {
showQuantity: !isOptionalLine,
showPrice: !isOptionalLine,
};
return props;
},
_prepareNewLineData(line, product) {
const data = super._prepareNewLineData(line, product);
if (this.env.shouldCollapse(line, 'is_optional')) {
data.quantity = 0;
}
return data;
}
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,63 @@
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
import { rpc } from "@web/core/network/rpc";
export class SaleUpdateLineButton extends Interaction {
static selector = ".o_portal_sale_sidebar";
dynamicContent = {
"a.js_update_line_json": {
"t-on-click.prevent.withTarget": this.onUpdateLineClick,
},
".js_quantity": {
"t-on-change.prevent.withTarget": this.onQuantityChange,
},
};
setup() {
this.orderDetail = this.el.querySelector("table#sales_order_table").dataset;
}
/**
* @param {number} orderId
* @param {Object} params
*/
callUpdateLineRoute(orderId, params) {
return rpc("/my/orders/" + orderId + "/update_line_dict", params);
}
refreshOrderUI() {
window.location.reload();
}
/**
* @param {MouseEvent} ev
* @param {HTMLElement} currentTargetEl
*/
async onQuantityChange(ev, currentTargetEl) {
const quantity = parseInt(currentTargetEl.value);
await this.waitFor(this.callUpdateLineRoute(this.orderDetail.orderId, {
"access_token": this.orderDetail.token,
"input_quantity": quantity >= 0 ? quantity : false,
"line_id": currentTargetEl.dataset.lineId,
}));
this.refreshOrderUI();
}
/**
* @param {MouseEvent} ev
* @param {HTMLElement} currentTargetEl
*/
async onUpdateLineClick(ev, currentTargetEl) {
await this.waitFor(this.callUpdateLineRoute(this.orderDetail.orderId, {
"access_token": this.orderDetail.token,
"line_id": currentTargetEl.dataset.lineId,
"remove": currentTargetEl.dataset.remove,
}));
this.refreshOrderUI();
}
}
registry
.category("public.interactions")
.add("sale_management.sale_update_line_button", SaleUpdateLineButton);

View file

@ -1,111 +0,0 @@
odoo.define('sale_management.sale_management', function (require) {
'use strict';
var publicWidget = require('web.public.widget');
publicWidget.registry.SaleUpdateLineButton = publicWidget.Widget.extend({
selector: '.o_portal_sale_sidebar',
events: {
'click a.js_update_line_json': '_onClickOptionQuantityButton',
'click a.js_add_optional_products': '_onClickAddOptionalProduct',
'change .js_quantity': '_onChangeOptionQuantity',
},
/**
* @override
*/
async start() {
await this._super(...arguments);
this.orderDetail = this.$el.find('table#sales_order_table').data();
},
/**
* Calls the route to get updated values of the line and order
* when the quantity of a product has changed
*
* @private
* @param {integer} order_id
* @param {Object} params
* @return {Deferred}
*/
_callUpdateLineRoute(order_id, params) {
return this._rpc({
route: "/my/orders/" + order_id + "/update_line_dict",
params: params,
});
},
/**
* Refresh the UI of the order details
*
* @private
* @param {Object} data: contains order html details
*/
_refreshOrderUI(data){
window.location.reload();
},
/**
* Process the change in line quantity
*
* @private
* @param {Event} ev
*/
async _onChangeOptionQuantity(ev) {
ev.preventDefault();
let self = this,
$target = $(ev.currentTarget),
quantity = parseInt($target.val());
const result = await this._callUpdateLineRoute(self.orderDetail.orderId, {
'line_id': $target.data('lineId'),
'input_quantity': quantity >= 0 ? quantity : false,
'access_token': self.orderDetail.token
});
this._refreshOrderUI(result);
},
/**
* Reacts to the click on the -/+ buttons
*
* @private
* @param {Event} ev
*/
async _onClickOptionQuantityButton(ev) {
ev.preventDefault();
let self = this,
$target = $(ev.currentTarget);
const result = await this._callUpdateLineRoute(self.orderDetail.orderId, {
'line_id': $target.data('lineId'),
'remove': $target.data('remove'),
'unlink': $target.data('unlink'),
'access_token': self.orderDetail.token
});
this._refreshOrderUI(result);
},
/**
* Triggered when optional product added to order from portal.
*
* @private
* @param {Event} ev
*/
_onClickAddOptionalProduct(ev) {
ev.preventDefault();
let self = this,
$target = $(ev.currentTarget);
// to avoid double click on link with href.
$target.css('pointer-events', 'none');
this._rpc({
route: "/my/orders/" + self.orderDetail.orderId + "/add_option/" + $target.data('optionId'),
params: {access_token: self.orderDetail.token}
}).then((data) => {
this._refreshOrderUI(data);
});
},
});
});

View file

@ -0,0 +1,381 @@
import { defineMailModels } from '@mail/../tests/mail_test_helpers';
import { expect, test } from '@odoo/hoot';
import { queryAllTexts } from '@odoo/hoot-dom';
import {
clickSave,
contains,
defineModels,
fields,
mountView,
onRpc,
} from '@web/../tests/web_test_helpers';
import { saleModels } from '@sale/../tests/sale_test_helpers';
class SaleOrderLine extends saleModels.SaleOrderLine {
// for skipping tax setup required for prices computation to run correctly
price_unit = fields.Float({ default: 3.00 });
price_total = fields.Float({ default: 3.00 });
price_subtotal = fields.Float({ default: 3.50 });
product_uom_qty = fields.Float({ default: 1.00 });
_records = [
{ id: 1, name: "r1", sequence: 1 },
{ id: 2, name: "r2", sequence: 2 },
{
id: 3,
name: "Sec1",
sequence: 3,
display_type: 'line_section',
product_uom_qty: 0,
price_unit: 0,
price_total: 0,
price_subtotal: 0,
collapse_prices: true,
},
{
id: 4,
name: "Sec2",
sequence: 4,
display_type: 'line_section',
product_uom_qty: 0,
price_unit: 0,
price_total: 0,
price_subtotal: 0,
collapse_composition: true,
},
{
id: 5,
name: "Sec3",
sequence: 5,
display_type: 'line_section',
product_uom_qty: 0,
price_unit: 0,
price_total: 0,
price_subtotal: 0,
},
{ id: 6, name: "Sec3-r1", sequence: 6 },
{ id: 7, name: "Sec3-r2", sequence: 7 },
{
id: 8,
name: "Sec3-sub1",
sequence: 8,
display_type: 'line_subsection',
product_uom_qty: 0,
price_unit: 0,
price_total: 0,
},
{ id: 9, name: "Sec3-sub1-r1", sequence: 9 },
{
id: 10,
name: "Sec3-sub2",
sequence: 10,
display_type: 'line_subsection',
product_uom_qty: 0,
price_unit: 0,
price_total: 0,
},
{ id: 11, name: "Sec3-sub2-r1", sequence: 11 },
{
id: 12,
name: "Sec4",
sequence: 12,
display_type: 'line_section',
product_uom_qty: 0,
price_unit: 0,
price_total: 0,
price_subtotal: 0
},
{ id: 13, name: "Sec4-r1", sequence: 13 },
{
id: 14,
name: "Sec4-sub1",
sequence: 14,
display_type: 'line_subsection',
product_uom_qty: 0,
price_unit: 0,
price_total: 0,
collapse_composition: true,
collapse_prices: true,
},
{ id: 15, name: "Sec4-sub1-r1", sequence: 15 },
];
}
class SaleOrder extends saleModels.SaleOrder {
_records = [
{
id: 1,
name: "Optional Sections Sale order",
order_line: SaleOrderLine._records.map(record => record.id),
},
];
_views = {
form: `
<form>
<field
name="order_line"
widget="sol_o2m"
options="{'subsections': True, 'hide_composition': True, 'hide_prices': True}"
>
<list editable="bottom">
<control>
<create name="add_line_control" string="Add a line"/>
<create name="add_section_control" string="Add a section" context="{'default_display_type': 'line_section'}"/>
<create name="add_note_control" string="Add a note" context="{'default_display_type': 'line_note'}"/>
</control>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="product_uom_qty"/>
<field name="price_unit"/>
<field name="price_total"/>
<field name="price_subtotal"/>
<field name="display_type" column_invisible="1"/>
<field name="collapse_composition" column_invisible="1"/>
<field name="collapse_prices" column_invisible="1"/>
<field name="is_optional" column_invisible="1"/>
</list>
</field>
</form>
`,
};
}
defineModels({ SaleOrderLine, SaleOrder });
defineMailModels();
const EXPECTED_LINE_RECORDS = [
"r1",
"r2",
"Sec1",
"Sec2",
"Sec3",
"Sec3-r1",
"Sec3-r2",
"Sec3-sub1",
"Sec3-sub1-r1",
"Sec3-sub2",
"Sec3-sub2-r1",
"Sec4",
"Sec4-r1",
"Sec4-sub1",
"Sec4-sub1-r1",
];
test("Can't mark section hidden if optional and vice versa", async () => {
await mountView({
type: 'form',
resModel: 'sale.order',
resId: 1,
});
expect(queryAllTexts('.o_data_row .o_list_text')).toEqual(EXPECTED_LINE_RECORDS);
await contains('.o_data_row:contains(Sec1) .o_list_section_options button').click();
expect('.o-dropdown-item:contains(Set Optional)').toHaveClass('disabled', {
message: "Section with hidden prices can't be optional"
});
await contains('.o_data_row:contains(Sec2) .o_list_section_options button').click();
expect('.o-dropdown-item:contains(Set Optional)').toHaveClass('disabled', {
message: "Hidden section can't be optional"
});
})
test("Setting section optional should reset some fields", async () => {
onRpc('web_save', ({ args }) => {
expect.step('web_save');
expect(args[1]).toEqual(
{
order_line: [
[1, 10, { collapse_composition: false, collapse_prices: false }],
[1, 8, { collapse_composition: false, collapse_prices: false }],
[1, 5, { is_optional: true }],
[1, 6, { product_uom_qty: 0, price_total: 0, price_subtotal: 0 }],
[1, 7, { product_uom_qty: 0, price_total: 0, price_subtotal: 0 }],
[1, 9, { product_uom_qty: 0, price_total: 0, price_subtotal: 0 }],
[1, 11, { product_uom_qty: 0, price_total: 0, price_subtotal: 0 }],
],
},
{ message: "Subsections reset collapse_* fields' value and product lines reset qty/price when section becomes optional" }
);
});
await mountView({
type: 'form',
resModel: 'sale.order',
resId: 1,
});
expect(queryAllTexts('.o_data_row .o_list_text')).toEqual(EXPECTED_LINE_RECORDS);
await contains('.o_data_row:contains(Sec3-sub2) .o_list_section_options button').click();
await contains('.o-dropdown-item:contains(Hide Composition)').click();
await contains('.o_data_row:contains(Sec3-sub1) .o_list_section_options button').click();
await contains('.o-dropdown-item:contains(Hide Prices)').click();
await contains('.o_data_row:contains(Sec3) .o_list_section_options button').click();
await contains('.o-dropdown-item:contains(Set Optional)').click();
await clickSave();
await expect.verifySteps(['web_save']);
})
test("Unsetting optional section should reset some fields", async () => {
SaleOrderLine._records.find(record => record.name === 'Sec3').is_optional = true;
SaleOrderLine._records.find(record => record.name === 'Sec3-r1').product_uom_qty = 0;
SaleOrderLine._records.find(record => record.name === 'Sec3-r2').product_uom_qty = 0;
SaleOrderLine._records.find(record => record.name === 'Sec3-sub1-r1').product_uom_qty = 0;
// This line should not be reset
SaleOrderLine._records.find(record => record.name === 'Sec3-sub2-r1').product_uom_qty = 5;
onRpc('web_save', ({ args }) => {
expect.step('web_save');
expect(args[1]).toEqual(
{
order_line: [
[1, 5, { is_optional: false }],
[1, 6, { product_uom_qty: 1 }],
[1, 7, { product_uom_qty: 1 }],
[1, 9, { product_uom_qty: 1 }],
[1, 11, { product_uom_qty: 5 }],
],
},
{ message: "The subsections should reset products lines with 0 quantity with 1 as soon as section becomes non optional" }
);
});
await mountView({
type: 'form',
resModel: 'sale.order',
resId: 1,
});
expect(queryAllTexts('.o_data_row .o_list_text')).toEqual(EXPECTED_LINE_RECORDS);
expect('.o_data_row:contains(Sec3-r1)').toHaveClass('text-primary', {
message: "Line under optional section should be text-primary"
});
expect('.o_data_row:contains(Sec3-sub1)').toHaveClass('text-primary', {
message: "Subsection under optional section should be text-primary"
});
expect('.o_data_row:contains(Sec3-sub1-r1)').toHaveClass('text-primary', {
message: "Line under subsection(which is under optional section) should be text-primary"
});
await contains('.o_data_row:contains(Sec3) .o_list_section_options button').click();
await contains('.o-dropdown-item:contains(Unset Optional)').click();
await clickSave();
await expect.verifySteps(['web_save']);
})
test("drag and drop regular line inside optional section resets some fields", async () => {
SaleOrderLine._records.find(record => record.name === 'Sec3').is_optional = true;
SaleOrderLine._records.find(record => record.name === 'Sec3-sub2-r1').product_uom_qty = 0;
SaleOrderLine._records.find(record => record.name === 'Sec3-sub1-r1').product_uom_qty = 1;
onRpc('web_save', ({ args }) => {
expect.step('web_save');
expect(args[1].order_line.find(commands => commands[1] === 13)[2].product_uom_qty).toEqual( // Sec4-r1
0,
{ message: "Drag and drop inside optional section should reset product_uom_qty to 0" },
);
expect(args[1].order_line.find(commands => commands[1] === 11)[2].product_uom_qty).toEqual( // Sec3-sub2-r1
1,
{ message: "Drag and drop line with 0 quantity outside optional section should reset product_uom_qty to 1" },
);
expect(args[1].order_line.find(commands => commands[1] === 9)?.[2].product_uom_qty).toEqual( // Sec3-sub1-r1
undefined,
{ message: "Drag and drop line with non-zero quantity outside optional section shouldn't reset product_uom_qty" }
);
})
await mountView({
type: 'form',
resModel: 'sale.order',
resId: 1,
});
expect(queryAllTexts('.o_data_row .o_list_text')).toEqual(EXPECTED_LINE_RECORDS);
await contains('.o_data_row:contains(Sec4-r1):first .o_row_handle').dragAndDrop('.o_data_row:contains(Sec3-sub2):first');
await contains('.o_data_row:contains(Sec3-sub2-r1):first .o_row_handle').dragAndDrop('.o_data_row:contains(Sec4-sub1):first');
await contains('.o_data_row:contains(Sec3-sub1-r1):first .o_row_handle').dragAndDrop('.o_data_row:contains(Sec4-sub1):first');
await clickSave();
await expect.verifySteps(['web_save']);
})
test("Moving Optional Sections to include some lines should set quantity to 0", async () => {
SaleOrderLine._records.find(record => record.name === 'Sec4').is_optional = true;
// keep sec4-r1's quantity 1 so that we can check that it doesn't reset
SaleOrderLine._records.find(record => record.name === 'Sec4-sub1-r1').product_uom_qty = 0;
onRpc('web_save', ({ args }) => {
expect.step('web_save');
expect(args[1].order_line.find(commands => commands[1] === 7)[2].product_uom_qty).toEqual( // Sec3-r2
0,
{ message: "New lines added to an optional section should have product_uom_qty set to 0" },
);
expect(args[1].order_line.find(commands => commands[1] === 9)[2].product_uom_qty).toEqual( // Sec3-sub1-r1
0,
{ message: "New lines added to a subsection of an optional section should also have product_uom_qty set to 0" },
);
expect(args[1].order_line.find(commands => commands[1] === 13)?.[2].product_uom_qty).toEqual( // Sec4-r1
undefined,
{ message: "Existing optional lines should keep their current product_uom_qty" }
);
});
await mountView({
type: 'form',
resModel: 'sale.order',
resId: 1,
});
expect(queryAllTexts('.o_data_row .o_list_text')).toEqual(EXPECTED_LINE_RECORDS);
await contains('.o_data_row:contains(Sec4):first .o_row_handle').dragAndDrop('.o_data_row:contains(Sec3-r2):first');
await clickSave();
await expect.verifySteps(['web_save']);
})
test("Moving Optional Sections to exclude some lines should set quantity to 1", async () => {
SaleOrderLine._records.find(record => record.name === 'Sec3').is_optional = true;
SaleOrderLine._records.find(record => record.name === 'Sec3-r1').product_uom_qty = 0;
SaleOrderLine._records.find(record => record.name === 'Sec3-sub1-r1').product_uom_qty = 0;
onRpc('web_save', ({ args }) => {
expect.step('web_save');
expect(args[1].order_line.find(command => command[1] === 6)[2].product_uom_qty).toEqual( // Sec3-r1
1,
{ message: "Non-optional lines should reset product_uom_qty to 1 when it was previously 0." },
);
expect(args[1].order_line.find(command => command[1] === 7)?.[2].product_uom_qty).toEqual( // Sec3-r2
undefined,
{ message: "Non-optional lines should keep their existing product_uom_qty when it was already non-zero." },
);
expect(args[1].order_line.find(command => command[1] === 9)[2].product_uom_qty).toEqual( // Sec3-sub1-r1
1,
{ message: "Lines moved out of an optional subsection should reset product_uom_qty to 1 when it was 0." },
);
expect(args[1].order_line.find(command => command[1] === 11)?.[2].product_uom_qty).toEqual( // Sec3-sub2-r1
undefined,
{ message: "Lines moved out of an optional subsection should keep their existing product_uom_qty when it was already non-zero." },
);
});
await mountView({
type: 'form',
resModel: 'sale.order',
resId: 1,
});
expect(queryAllTexts('.o_data_row .o_list_text')).toEqual(EXPECTED_LINE_RECORDS);
await contains('.o_data_row:contains(Sec3):first .o_row_handle').dragAndDrop('.o_data_row:contains(Sec4):first');
await clickSave();
await expect.verifySteps(['web_save']);
})

View file

@ -0,0 +1,272 @@
import { defineMailModels } from '@mail/../tests/mail_test_helpers';
import { expect, test } from '@odoo/hoot';
import { queryAllTexts } from '@odoo/hoot-dom';
import {
clickSave,
contains,
defineModels,
fields,
models,
mountView,
onRpc,
} from '@web/../tests/web_test_helpers';
class SaleOrderTemplateLine extends models.ServerModel {
_name = 'sale.order.template.line';
product_uom_qty = fields.Float({ default: 1.00 });
_records = [
{ id: 1, name: "r1", sequence: 1 },
{ id: 2, name: "r2", sequence: 2 },
{
id: 3,
name: "Sec1",
sequence: 3,
display_type: 'line_section',
product_uom_qty: 0,
},
{
id: 4,
name: "Sec2",
sequence: 4,
display_type: 'line_section',
product_uom_qty: 0,
},
{
id: 5,
name: "Sec3",
sequence: 5,
display_type: 'line_section',
product_uom_qty: 0,
},
{ id: 6, name: "Sec3-r1", sequence: 6 },
{ id: 7, name: "Sec3-r2", sequence: 7 },
{
id: 8,
name: "Sec3-sub1",
sequence: 8,
display_type: 'line_subsection',
product_uom_qty: 0,
},
{ id: 9, name: "Sec3-sub1-r1", sequence: 9 },
{
id: 10,
name: "Sec3-sub2",
sequence: 10,
display_type: 'line_subsection',
product_uom_qty: 0,
},
{ id: 11, name: "Sec3-sub2-r1", sequence: 11 },
{
id: 12,
name: "Sec4",
sequence: 12,
display_type: 'line_section',
product_uom_qty: 0,
},
{ id: 13, name: "Sec4-r1", sequence: 13 },
{
id: 14,
name: "Sec4-sub1",
sequence: 14,
display_type: 'line_subsection',
product_uom_qty: 0,
},
{ id: 15, name: "Sec4-sub1-r1", sequence: 15 },
];
}
class SaleOrderTemplate extends models.ServerModel {
_name = 'sale.order.template';
_records = [
{
id: 1,
name: "Optional Sections Sale order template",
sale_order_template_line_ids: SaleOrderTemplateLine._records.map(record => record.id),
},
];
_views = {
form: `
<form>
<field
name="sale_order_template_line_ids"
widget="so_template_line_o2m"
options="{'subsections': True}"
>
<list editable="bottom">
<control>
<create name="add_line_control" string="Add a line"/>
<create name="add_section_control" string="Add a section" context="{'default_display_type': 'line_section'}"/>
<create name="add_note_control" string="Add a note" context="{'default_display_type': 'line_note'}"/>
</control>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="product_uom_qty"/>
<field name="display_type" column_invisible="1"/>
<field name="is_optional" column_invisible="1"/>
</list>
</field>
</form>
`,
};
}
defineModels({ SaleOrderTemplateLine, SaleOrderTemplate });
defineMailModels();
const EXPECTED_LINE_RECORDS = [
"r1",
"r2",
"Sec1",
"Sec2",
"Sec3",
"Sec3-r1",
"Sec3-r2",
"Sec3-sub1",
"Sec3-sub1-r1",
"Sec3-sub2",
"Sec3-sub2-r1",
"Sec4",
"Sec4-r1",
"Sec4-sub1",
"Sec4-sub1-r1",
];
test("drag and drop regular template lines inside optional section resets some fields", async () => {
SaleOrderTemplateLine._records.find(record => record.name === 'Sec3').is_optional = true;
SaleOrderTemplateLine._records.find(record => record.name === 'Sec3-sub2-r1').product_uom_qty = 0;
SaleOrderTemplateLine._records.find(record => record.name === 'Sec3-sub1-r1').product_uom_qty = 1;
onRpc('web_save', ({ args }) => {
expect.step('web_save');
expect(
args[1].sale_order_template_line_ids.find(
commands => commands[1] === 13 // Sec4-r1
)[2].product_uom_qty
).toEqual(0, {
message: "Drag and drop inside optional section should reset product_uom_qty to 0"
});
expect(
args[1].sale_order_template_line_ids.find(
commands => commands[1] === 11 // Sec3-sub2-r1
)[2].product_uom_qty
).toEqual(1, {
message: "Drag and drop line with 0 quantity outside optional section should reset product_uom_qty to 1"
});
expect(
args[1].sale_order_template_line_ids.find(
commands => commands[1] === 9 // Sec3-sub1-r1
)?.[2].product_uom_qty
).toEqual(undefined, {
message: "Drag and drop line with non-zero quantity outside optional section shouldn't reset product_uom_qty"
});
})
await mountView({
type: 'form',
resModel: 'sale.order.template',
resId: 1,
});
expect(queryAllTexts('.o_data_row .o_list_text')).toEqual(EXPECTED_LINE_RECORDS);
await contains('.o_data_row:contains(Sec4-r1):first .o_row_handle').dragAndDrop('.o_data_row:contains(Sec3-sub2):first');
await contains('.o_data_row:contains(Sec3-sub2-r1):first .o_row_handle').dragAndDrop('.o_data_row:contains(Sec4-sub1):first');
await contains('.o_data_row:contains(Sec3-sub1-r1):first .o_row_handle').dragAndDrop('.o_data_row:contains(Sec4-sub1):first');
await clickSave();
await expect.verifySteps(['web_save']);
})
test("Moving Optional Sections to include some template lines should set quantity to 0", async () => {
SaleOrderTemplateLine._records.find(record => record.name === 'Sec4').is_optional = true;
SaleOrderTemplateLine._records.find(record => record.name === 'Sec4-sub1-r1').product_uom_qty = 0;
onRpc('web_save', ({ args }) => {
expect.step('web_save');
expect(
args[1].sale_order_template_line_ids.find(
commands => commands[1] === 7 // Sec3-r2
)[2].product_uom_qty
).toEqual(0, {
message: "New lines added to an optional section should have product_uom_qty set to 0",
});
expect(
args[1].sale_order_template_line_ids.find(
commands => commands[1] === 9 // Sec3-sub1-r1
)[2].product_uom_qty
).toEqual(0, {
message: "New lines added to a subsection of an optional section should also have product_uom_qty set to 0",
});
expect(
args[1].sale_order_template_line_ids.find(
commands => commands[1] === 13 // Sec4-r1
)?.[2].product_uom_qty
).toEqual(undefined, {
message: "Existing optional lines should keep their current product_uom_qty",
});
});
await mountView({
type: 'form',
resModel: 'sale.order.template',
resId: 1,
});
expect(queryAllTexts('.o_data_row .o_list_text')).toEqual(EXPECTED_LINE_RECORDS);
await contains('.o_data_row:contains(Sec4):first .o_row_handle').dragAndDrop('.o_data_row:contains(Sec3-r2):first');
await clickSave();
await expect.verifySteps(['web_save']);
})
test("Moving Optional Sections to exclude some template lines should set quantity to 1", async () => {
SaleOrderTemplateLine._records.find(record => record.name === 'Sec3').is_optional = true;
SaleOrderTemplateLine._records.find(record => record.name === 'Sec3-r1').product_uom_qty = 0;
SaleOrderTemplateLine._records.find(record => record.name === 'Sec3-sub1-r1').product_uom_qty = 0;
onRpc('web_save', ({ args }) => {
expect.step('web_save');
expect(
args[1].sale_order_template_line_ids.find(
command => command[1] === 6 // Sec3-r1
)[2].product_uom_qty
).toEqual(1, {
message: "Non-optional lines should reset product_uom_qty to 1 when it was previously 0.",
});
expect(
args[1].sale_order_template_line_ids.find(
command => command[1] === 7 // Sec3-r2
)?.[2].product_uom_qty
).toEqual(undefined, {
message: "Non-optional lines should keep their existing product_uom_qty when it was already non-zero.",
});
expect(
args[1].sale_order_template_line_ids.find(
command => command[1] === 9 // Sec3-sub1-r1
)[2].product_uom_qty
).toEqual(1, {
message: "Lines moved out of an optional subsection should reset product_uom_qty to 1 when it was 0.",
});
expect(
args[1].sale_order_template_line_ids.find(
command => command[1] === 11 // Sec3-sub2-r1
)?.[2].product_uom_qty
).toEqual(undefined, {
message: "Lines moved out of an optional subsection should keep their existing product_uom_qty when it was already non-zero.",
});
});
await mountView({
type: 'form',
resModel: 'sale.order.template',
resId: 1,
});
expect(queryAllTexts('.o_data_row .o_list_text')).toEqual(EXPECTED_LINE_RECORDS);
await contains('.o_data_row:contains(Sec3):first .o_row_handle').dragAndDrop('.o_data_row:contains(Sec4):first');
await clickSave();
await expect.verifySteps(['web_save']);
})

View file

@ -0,0 +1,40 @@
import { registry } from "@web/core/registry";
import { stepUtils } from "@web_tour/tour_utils";
import tourUtils from "@sale/js/tours/tour_utils";
registry.category("web_tour.tours").add("test_basic_sale_flow_with_minimal_access_rights", {
steps: () => [
...stepUtils.goToAppSteps("sale.sale_menu_root", "Open the sales app"),
{
content: "Check that at least one quotation is present in the view",
trigger: ".o_sale_onboarding_list_view .o_data_row",
},
...tourUtils.createNewSalesOrder(),
...tourUtils.selectCustomer("partner_a"),
...tourUtils.addProduct("Test Product"),
tourUtils.checkSOLDescriptionContains("Test Product"),
{
trigger: "button[name=action_confirm]",
run: "click",
},
{
trigger: ".o_statusbar_status .o_arrow_button_current:contains(Sales Order)",
},
{
trigger: "button[id=create_invoice]",
run: "click",
},
{
trigger: ".modal-content button[id=create_invoice_open]",
run: "click",
},
{
content: "Check that we are in the invoice form view",
trigger: ".o_statusbar_status:contains(Posted) .o_arrow_button_current:contains(Draft)",
},
{
content: "Check that the invoice is linked to the sale order",
trigger: "button[name=action_view_source_sale_orders] .o_stat_value:contains(1)",
},
],
});