19.0 vanilla
|
Before Width: | Height: | Size: 6 KiB After Width: | Height: | Size: 861 B |
|
|
@ -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 |
|
After Width: | Height: | Size: 4.4 KiB |
|
|
@ -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 map’s 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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 map’s 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);
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
Before Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
});
|
||||
},
|
||||
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
})
|
||||
|
|
@ -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']);
|
||||
})
|
||||
|
|
@ -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)",
|
||||
},
|
||||
],
|
||||
});
|
||||