[IMP] web,*: status bar auto-resize
Before this commit, the status bar field could be displayed in 2 ways: - in small screens: as a dropdown - in larger screens: as an inline list of buttons This commit centralizes both of these approaches into a dynamic display that fits the available space: - the current stage is always displayed; - previous stages are displayed behind or contained in a dropdown if not enough space; - next stages are displayed behind or contained in a dropdown if not enough space. Task 3336856 closes odoo/odoo#124267 Related: odoo/enterprise#48706 Signed-off-by: Mathieu Duckerts-Antoine (dam) <dam@odoo.com>
This commit is contained in:
+13
-36
@@ -3,42 +3,27 @@
|
||||
import { formatDuration } from "@web/core/l10n/dates";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import {
|
||||
preloadStatusBar,
|
||||
statusBarField,
|
||||
StatusBarField,
|
||||
} from "@web/views/fields/statusbar/statusbar_field";
|
||||
import { statusBarField, StatusBarField } from "@web/views/fields/statusbar/statusbar_field";
|
||||
|
||||
export class StatusBarDurationField extends StatusBarField {
|
||||
static template = "mail.StatusBarDurationField";
|
||||
|
||||
computeItems(grouped = true) {
|
||||
const items = super.computeItems(grouped);
|
||||
if (!grouped) {
|
||||
return items;
|
||||
}
|
||||
if (this.props.record.data.duration_tracking) {
|
||||
for (const property in items) {
|
||||
this.setStatusbarDuration(items[property]);
|
||||
getAllItems() {
|
||||
const items = super.getAllItems();
|
||||
const durationTracking = this.props.record.data.duration_tracking || {};
|
||||
if (Object.keys(durationTracking).length) {
|
||||
for (const item of items) {
|
||||
const duration = durationTracking[item.value];
|
||||
if (duration > 0) {
|
||||
item.shortTimeInStage = formatDuration(duration, false);
|
||||
item.fullTimeInStage = formatDuration(duration, true);
|
||||
} else {
|
||||
item.shortTimeInStage = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
setStatusbarDuration(stages) {
|
||||
const durationTracking = this.props.record.data.duration_tracking;
|
||||
if (!Object.keys(durationTracking).length) {
|
||||
return;
|
||||
}
|
||||
for (const stage of stages) {
|
||||
if (stage.id in durationTracking && durationTracking[stage.id] > 0) {
|
||||
stage.shortTimeInStage = formatDuration(durationTracking[stage.id], false);
|
||||
stage.fullTimeInStage = formatDuration(durationTracking[stage.id], true);
|
||||
} else {
|
||||
stage.shortTimeInStage = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const statusBarDurationField = {
|
||||
@@ -50,11 +35,3 @@ export const statusBarDurationField = {
|
||||
};
|
||||
|
||||
registry.category("fields").add("statusbar_duration", statusBarDurationField);
|
||||
|
||||
registry.category("preloadedData").add("statusbar_duration", {
|
||||
loadOnTypes: ["many2one"],
|
||||
extraMemoizationKey: (record, fieldName) => {
|
||||
return record.data[fieldName];
|
||||
},
|
||||
preload: preloadStatusBar,
|
||||
});
|
||||
|
||||
+22
-36
@@ -1,46 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<!-- Add time to the stage buttons -->
|
||||
|
||||
<t t-name="mail.StatusBarDurationField" t-inherit="web.StatusBarField" t-inherit-mode="primary">
|
||||
|
||||
<!-- Add time to the stage buttons -->
|
||||
<xpath expr="//DropdownItem/t[1]" position="replace">
|
||||
<span t-esc="item.name" />
|
||||
<t t-name="mail.StatusBarDurationField.Dropdown" t-inherit="web.StatusBarField.Dropdown" t-inherit-mode="primary">
|
||||
<xpath expr="//*[@t-esc='item.label']" position="inside">
|
||||
<span t-esc="item.label" />
|
||||
<span t-if="item.shortTimeInStage" t-att-title="item.fullTimeInStage" class="ms-2 opacity-50 small">
|
||||
<t t-esc="item.shortTimeInStage"/>
|
||||
<t t-esc="item.shortTimeInStage" />
|
||||
</span>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//t[@t-if='item.isSelected']/button" position="attributes">
|
||||
<attribute name="t-esc"/>
|
||||
<xpath expr="//*[@t-esc='item.label']" position="attributes">
|
||||
<attribute name="t-esc" />
|
||||
</xpath>
|
||||
<xpath expr="//t[@t-if='item.isSelected']/button" position="inside">
|
||||
<span t-esc="item.name"/>
|
||||
<span t-if="item.shortTimeInStage" t-att-title="item.fullTimeInStage" class="ms-2 opacity-50 small">
|
||||
<t t-esc="item.shortTimeInStage"/>
|
||||
</span>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//t[@t-elif='props.isDisabled']/button" position="attributes">
|
||||
<attribute name="t-esc"/>
|
||||
</xpath>
|
||||
<xpath expr="//t[@t-elif='props.isDisabled']/button" position="inside">
|
||||
<span t-esc="item.name"/>
|
||||
<span t-if="item.shortTimeInStage" t-att-title="item.fullTimeInStage" class="ms-2 opacity-50 small">
|
||||
<t t-esc="item.shortTimeInStage"/>
|
||||
</span>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//t[@t-else='']/button" position="attributes">
|
||||
<attribute name="t-esc"/>
|
||||
</xpath>
|
||||
<xpath expr="//t[@t-else='']/button" position="inside">
|
||||
<span t-esc="item.name"/>
|
||||
<span t-if="item.shortTimeInStage" t-att-title="item.fullTimeInStage" class="ms-2 opacity-50 small">
|
||||
<t t-esc="item.shortTimeInStage"/>
|
||||
</span>
|
||||
</xpath>
|
||||
|
||||
</t>
|
||||
|
||||
<t t-name="mail.StatusBarDurationField" t-inherit="web.StatusBarField" t-inherit-mode="primary">
|
||||
<xpath expr="//*[@t-call='web.StatusBarField.Dropdown']" position="attributes">
|
||||
<attribute name="t-call">mail.StatusBarDurationField.Dropdown</attribute>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//*[@t-esc='item.label']" position="inside">
|
||||
<span t-esc="item.label" />
|
||||
<span t-if="item.shortTimeInStage" t-att-title="item.fullTimeInStage" class="ms-2 opacity-50 small">
|
||||
<t t-esc="item.shortTimeInStage" />
|
||||
</span>
|
||||
</xpath>
|
||||
<xpath expr="//*[@t-esc='item.label']" position="attributes">
|
||||
<attribute name="t-esc" />
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
@@ -6,101 +6,127 @@ import { stepUtils } from "@web_tour/tour_service/tour_utils";
|
||||
|
||||
import PurchaseAdditionalTourSteps from "@purchase/js/tours/purchase_steps";
|
||||
|
||||
registry.category("web_tour.tours").add('purchase_tour' , {
|
||||
registry.category("web_tour.tours").add("purchase_tour", {
|
||||
url: "/web",
|
||||
sequence: 40,
|
||||
steps: () => [stepUtils.showAppsMenuItem(), {
|
||||
trigger: '.o_app[data-menu-xmlid="purchase.menu_purchase_root"]',
|
||||
content: _t("Let's try the Purchase app to manage the flow from purchase to reception and invoice control."),
|
||||
position: 'right',
|
||||
edition: 'community'
|
||||
}, {
|
||||
trigger: '.o_app[data-menu-xmlid="purchase.menu_purchase_root"]',
|
||||
content: _t("Let's try the Purchase app to manage the flow from purchase to reception and invoice control."),
|
||||
position: 'bottom',
|
||||
edition: 'enterprise'
|
||||
}, {
|
||||
trigger: ".o_list_button_add",
|
||||
extra_trigger: ".o_purchase_order",
|
||||
content: _t("Let's create your first request for quotation."),
|
||||
position: "bottom",
|
||||
}, {
|
||||
trigger: ".o_form_editable .o_field_many2one[name='partner_id']",
|
||||
extra_trigger: ".o_purchase_order",
|
||||
content: _t("Search a vendor name, or create one on the fly."),
|
||||
position: "bottom",
|
||||
run: function (actions) {
|
||||
actions.text("Agrolait", this.$anchor.find("input"));
|
||||
},
|
||||
}, {
|
||||
trigger: ".ui-menu-item > a",
|
||||
auto: true,
|
||||
in_modal: false,
|
||||
}, {
|
||||
trigger: ".o_field_x2many_list_row_add > a",
|
||||
extra_trigger: ".o_field_many2one[name='partner_id'] .o_external_button",
|
||||
content: _t("Add some products or services to your quotation."),
|
||||
position: "bottom",
|
||||
}, {
|
||||
trigger: ".o_field_widget[name=product_id], .o_field_widget[name=product_template_id]",
|
||||
extra_trigger: ".o_purchase_order",
|
||||
content: _t("Select a product, or create a new one on the fly."),
|
||||
position: "right",
|
||||
run: function (actions) {
|
||||
var $input = this.$anchor.find('input');
|
||||
actions.text("DESK0001", $input.length === 0 ? this.$anchor : $input);
|
||||
// fake keydown to trigger search
|
||||
var keyDownEvent = jQuery.Event("keydown");
|
||||
keyDownEvent.which = 42;
|
||||
this.$anchor.trigger(keyDownEvent);
|
||||
var $descriptionElement = $('.o_form_editable textarea[name="name"]');
|
||||
// when description changes, we know the product has been created
|
||||
$descriptionElement.change(function () {
|
||||
$descriptionElement.addClass('product_creation_success');
|
||||
});
|
||||
},
|
||||
}, {
|
||||
trigger: '.ui-menu.ui-widget .ui-menu-item a:contains("DESK0001")',
|
||||
auto: true,
|
||||
}, {
|
||||
trigger: '.o_form_editable textarea[name="name"].product_creation_success',
|
||||
auto: true,
|
||||
run: function () {} // wait for product creation
|
||||
}, {
|
||||
trigger: ".o_form_editable input[name='product_qty'] ",
|
||||
extra_trigger: ".o_purchase_order",
|
||||
content: _t("Indicate the product quantity you want to order."),
|
||||
position: "right",
|
||||
run: 'text 12.0'
|
||||
},
|
||||
...stepUtils.statusbarButtonsSteps('Send by Email', _t("Send the request for quotation to your vendor."), ".o_statusbar_buttons button[name='action_rfq_send']"),
|
||||
{
|
||||
trigger: ".modal-content",
|
||||
auto: true,
|
||||
run: function(actions){
|
||||
// Check in case user must add email to vendor
|
||||
var $input = $(".modal-content input[name='email']");
|
||||
if ($input.length) {
|
||||
actions.text("agrolait@example.com", $input);
|
||||
actions.click($(".modal-footer button"));
|
||||
}
|
||||
}
|
||||
}, {
|
||||
trigger: ".modal-footer button[name='action_send_mail']",
|
||||
extra_trigger: ".modal-footer button[name='action_send_mail']",
|
||||
content: _t("Send the request for quotation to your vendor."),
|
||||
position: "left",
|
||||
run: 'click',
|
||||
}, {
|
||||
trigger: ".o_field_widget [name=price_unit]",
|
||||
extra_trigger: ".o_purchase_order",
|
||||
content: _t("Once you get the price from the vendor, you can complete the purchase order with the right price."),
|
||||
position: "right",
|
||||
run: 'text 200.00'
|
||||
}, {
|
||||
auto: true,
|
||||
trigger: ".o_purchase_order",
|
||||
run: 'click',
|
||||
}, ...stepUtils.statusbarButtonsSteps('Confirm Order', _t("Confirm your purchase.")),
|
||||
...new PurchaseAdditionalTourSteps()._get_purchase_stock_steps(),
|
||||
]});
|
||||
steps: () => [
|
||||
stepUtils.showAppsMenuItem(),
|
||||
{
|
||||
trigger: '.o_app[data-menu-xmlid="purchase.menu_purchase_root"]',
|
||||
content: _t(
|
||||
"Let's try the Purchase app to manage the flow from purchase to reception and invoice control."
|
||||
),
|
||||
position: "right",
|
||||
edition: "community",
|
||||
},
|
||||
{
|
||||
trigger: '.o_app[data-menu-xmlid="purchase.menu_purchase_root"]',
|
||||
content: _t(
|
||||
"Let's try the Purchase app to manage the flow from purchase to reception and invoice control."
|
||||
),
|
||||
position: "bottom",
|
||||
edition: "enterprise",
|
||||
},
|
||||
{
|
||||
trigger: ".o_list_button_add",
|
||||
extra_trigger: ".o_purchase_order",
|
||||
content: _t("Let's create your first request for quotation."),
|
||||
position: "bottom",
|
||||
},
|
||||
{
|
||||
trigger: ".o_form_editable .o_field_many2one[name='partner_id']",
|
||||
extra_trigger: ".o_purchase_order",
|
||||
content: _t("Search a vendor name, or create one on the fly."),
|
||||
position: "bottom",
|
||||
run: function (actions) {
|
||||
actions.text("Agrolait", this.$anchor.find("input"));
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: ".ui-menu-item > a",
|
||||
auto: true,
|
||||
in_modal: false,
|
||||
},
|
||||
{
|
||||
trigger: ".o_field_x2many_list_row_add > a",
|
||||
extra_trigger: ".o_field_many2one[name='partner_id'] .o_external_button",
|
||||
content: _t("Add some products or services to your quotation."),
|
||||
position: "bottom",
|
||||
},
|
||||
{
|
||||
trigger: ".o_field_widget[name=product_id], .o_field_widget[name=product_template_id]",
|
||||
extra_trigger: ".o_purchase_order",
|
||||
content: _t("Select a product, or create a new one on the fly."),
|
||||
position: "right",
|
||||
run: function (actions) {
|
||||
var $input = this.$anchor.find("input");
|
||||
actions.text("DESK0001", $input.length === 0 ? this.$anchor : $input);
|
||||
// fake keydown to trigger search
|
||||
var keyDownEvent = jQuery.Event("keydown");
|
||||
keyDownEvent.which = 42;
|
||||
this.$anchor.trigger(keyDownEvent);
|
||||
var $descriptionElement = $('.o_form_editable textarea[name="name"]');
|
||||
// when description changes, we know the product has been created
|
||||
$descriptionElement.change(function () {
|
||||
$descriptionElement.addClass("product_creation_success");
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: '.ui-menu.ui-widget .ui-menu-item a:contains("DESK0001")',
|
||||
auto: true,
|
||||
},
|
||||
{
|
||||
trigger: '.o_form_editable textarea[name="name"].product_creation_success',
|
||||
auto: true,
|
||||
run: function () {}, // wait for product creation
|
||||
},
|
||||
{
|
||||
trigger: ".o_form_editable input[name='product_qty'] ",
|
||||
extra_trigger: ".o_purchase_order",
|
||||
content: _t("Indicate the product quantity you want to order."),
|
||||
position: "right",
|
||||
run: "text 12.0",
|
||||
},
|
||||
...stepUtils.statusbarButtonsSteps(
|
||||
"Send by Email",
|
||||
_t("Send the request for quotation to your vendor."),
|
||||
".o_statusbar_buttons .o_arrow_button_current[name='action_rfq_send']"
|
||||
),
|
||||
{
|
||||
trigger: ".modal-content",
|
||||
auto: true,
|
||||
run: function (actions) {
|
||||
// Check in case user must add email to vendor
|
||||
var $input = $(".modal-content input[name='email']");
|
||||
if ($input.length) {
|
||||
actions.text("agrolait@example.com", $input);
|
||||
actions.click($(".modal-footer button"));
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: ".modal-footer button[name='action_send_mail']",
|
||||
extra_trigger: ".modal-footer button[name='action_send_mail']",
|
||||
content: _t("Send the request for quotation to your vendor."),
|
||||
position: "left",
|
||||
run: "click",
|
||||
},
|
||||
{
|
||||
trigger: ".o_field_widget [name=price_unit]",
|
||||
extra_trigger: ".o_purchase_order",
|
||||
content: _t(
|
||||
"Once you get the price from the vendor, you can complete the purchase order with the right price."
|
||||
),
|
||||
position: "right",
|
||||
run: "text 200.00",
|
||||
},
|
||||
{
|
||||
auto: true,
|
||||
trigger: ".o_purchase_order",
|
||||
run: "click",
|
||||
},
|
||||
...stepUtils.statusbarButtonsSteps("Confirm Order", _t("Confirm your purchase.")),
|
||||
...new PurchaseAdditionalTourSteps()._get_purchase_stock_steps(),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -35,6 +35,8 @@ import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog
|
||||
* @property {boolean} [unlink]
|
||||
* @property {boolean} [write]
|
||||
* @property {Function | null} onDelete
|
||||
*
|
||||
* @typedef {import("services").Services} Services
|
||||
*/
|
||||
|
||||
import {
|
||||
@@ -141,6 +143,10 @@ export function useActiveActions({
|
||||
return activeActions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T, [Props=any], [Env=any]
|
||||
* @param {(orm: Services["orm"], props: Component<Props, Env>["props"]) => Promise<T>} loadFn
|
||||
*/
|
||||
export function useSpecialData(loadFn) {
|
||||
const component = useComponent();
|
||||
const record = component.props.record;
|
||||
@@ -156,7 +162,8 @@ export function useSpecialData(loadFn) {
|
||||
}
|
||||
ormWithCache.call = (...args) => specialDataCaches[key].read(...args);
|
||||
|
||||
const result = useState({});
|
||||
/** @type {{ data: Record<string, T> }} */
|
||||
const result = useState({ data: {} });
|
||||
useRecordObserver(async (record, props) => {
|
||||
result.data = await loadFn(ormWithCache, { ...props, record });
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { Component, onWillRender, useEffect, useExternalListener, useRef } from "@odoo/owl";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { useCommand } from "@web/core/commands/command_hook";
|
||||
import { Domain } from "@web/core/domain";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
@@ -9,9 +10,41 @@ import { _t } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { groupBy } from "@web/core/utils/arrays";
|
||||
import { escape } from "@web/core/utils/strings";
|
||||
import { throttleForAnimation } from "@web/core/utils/timing";
|
||||
import { useSpecialData } from "@web/views/fields/relational_utils";
|
||||
import { standardFieldProps } from "../standard_field_props";
|
||||
|
||||
/**
|
||||
* @typedef {import("../standard_field_props").StandardFieldProps & {
|
||||
* domain?: typeof Domain;
|
||||
* foldField?: string;
|
||||
* isDisabled?: boolean;
|
||||
* visibleSelection?: string[];
|
||||
* withCommand?: boolean;
|
||||
* }} StatusBarFieldProps
|
||||
*
|
||||
* @typedef StatusBarItem
|
||||
* @property {number} value
|
||||
* @property {string} label
|
||||
* @property {boolean} isFolded
|
||||
* @property {boolean} isSelected
|
||||
*
|
||||
* @typedef StatusBarList
|
||||
* @property {string} label
|
||||
* @property {StatusBarItem[]} items
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {...HTMLElement} els
|
||||
*/
|
||||
const hide = (...els) => els.forEach((el) => el.classList.add("d-none"));
|
||||
|
||||
/**
|
||||
* @param {...HTMLElement} els
|
||||
*/
|
||||
const show = (...els) => els.forEach((el) => el.classList.remove("d-none"));
|
||||
|
||||
/** @extends {Component<StatusBarFieldProps>} */
|
||||
export class StatusBarField extends Component {
|
||||
static template = "web.StatusBarField";
|
||||
static components = {
|
||||
@@ -20,81 +53,46 @@ export class StatusBarField extends Component {
|
||||
};
|
||||
static props = {
|
||||
...standardFieldProps,
|
||||
canCreate: { type: Boolean, optional: true },
|
||||
canWrite: { type: Boolean, optional: true },
|
||||
displayName: { type: String, optional: true },
|
||||
isDisabled: { type: Boolean, optional: true },
|
||||
visibleSelection: { type: Array, optional: true },
|
||||
withCommand: { type: Boolean, optional: true },
|
||||
foldField: { type: String, optional: true },
|
||||
domain: { type: typeof Domain, optional: true },
|
||||
};
|
||||
static defaultProps = {
|
||||
visibleSelection: [],
|
||||
foldField: { type: String, optional: true },
|
||||
isDisabled: { type: Boolean, optional: true },
|
||||
visibleSelection: { type: Array, element: String, optional: true },
|
||||
withCommand: { type: Boolean, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
if (this.props.withCommand) {
|
||||
const commandName = _t("Move to %s...", escape(this.displayName));
|
||||
useCommand(
|
||||
commandName,
|
||||
() => {
|
||||
return {
|
||||
placeholder: commandName,
|
||||
providers: [
|
||||
{
|
||||
provide: () =>
|
||||
this.computeItems(false).map((value) => ({
|
||||
name: value.name,
|
||||
action: () => {
|
||||
this.selectItem(value);
|
||||
},
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
{
|
||||
category: "smart_action",
|
||||
hotkey: "alt+shift+x",
|
||||
isAvailable: () => !this.props.readonly && !this.props.isDisabled,
|
||||
}
|
||||
);
|
||||
useCommand(
|
||||
_t("Move to next %s", this.displayName),
|
||||
() => {
|
||||
const options = this.computeItems(false);
|
||||
const nextOption =
|
||||
options[
|
||||
options.findIndex(
|
||||
(option) =>
|
||||
option.id ===
|
||||
(this.type === "many2one"
|
||||
? this.props.record.data[this.props.name][0]
|
||||
: this.props.record.data[this.props.name])
|
||||
) + 1
|
||||
];
|
||||
this.selectItem(nextOption);
|
||||
},
|
||||
{
|
||||
category: "smart_action",
|
||||
hotkey: "alt+x",
|
||||
isAvailable: () => {
|
||||
const options = this.computeItems(false);
|
||||
return (
|
||||
!this.props.readonly &&
|
||||
!this.props.isDisabled &&
|
||||
options[options.length - 1].id !==
|
||||
(this.type === "many2one"
|
||||
? this.props.record.data[this.props.name][0]
|
||||
: this.props.record.data[this.props.name])
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
// Properties
|
||||
this.items = {};
|
||||
this.beforeRef = useRef("before");
|
||||
this.rootRef = useRef("root");
|
||||
this.afterRef = useRef("after");
|
||||
this.dropdownRef = useRef("dropdown");
|
||||
|
||||
if (this.type === "many2one") {
|
||||
// Resize listeners
|
||||
let status = "idle";
|
||||
const adjust = () => {
|
||||
status = "adjusting";
|
||||
this.adjustVisibleItems();
|
||||
this.render();
|
||||
browser.requestAnimationFrame(() => (status = "idle"));
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => status === "shouldAdjust" && adjust(),
|
||||
() => [status]
|
||||
);
|
||||
|
||||
onWillRender(() => {
|
||||
if (status !== "adjusting") {
|
||||
Object.assign(this.items, this.getSortedItems());
|
||||
status = "shouldAdjust";
|
||||
}
|
||||
});
|
||||
|
||||
useExternalListener(window, "resize", throttleForAnimation(adjust));
|
||||
|
||||
// Special data
|
||||
if (this.field.type === "many2one") {
|
||||
this.specialData = useSpecialData((orm, props) => {
|
||||
const { foldField, name: fieldName, record } = props;
|
||||
const { relation } = record.fields[fieldName];
|
||||
@@ -112,45 +110,168 @@ export class StatusBarField extends Component {
|
||||
return orm.searchRead(relation, domain, fieldNames);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get currentName() {
|
||||
switch (this.type) {
|
||||
case "many2one": {
|
||||
const item = this.options.find(
|
||||
(item) =>
|
||||
this.props.record.data[this.props.name] &&
|
||||
item.id === this.props.record.data[this.props.name][0]
|
||||
);
|
||||
return item ? item.display_name : "";
|
||||
}
|
||||
case "selection": {
|
||||
const item = this.options.find(
|
||||
(item) => item[0] === this.props.record.data[this.props.name]
|
||||
);
|
||||
return item ? item[1] : "";
|
||||
}
|
||||
}
|
||||
throw new Error("Unsupported field type for StatusBarField");
|
||||
}
|
||||
get options() {
|
||||
switch (this.type) {
|
||||
case "many2one":
|
||||
return this.specialData.data;
|
||||
case "selection":
|
||||
return this.props.record.fields[this.props.name].selection;
|
||||
default:
|
||||
return [];
|
||||
// Command palette
|
||||
if (this.props.withCommand) {
|
||||
const moveToCommandName = _t("Move to %s...", escape(this.field.string));
|
||||
useCommand(
|
||||
moveToCommandName,
|
||||
() => ({
|
||||
placeholder: moveToCommandName,
|
||||
providers: [
|
||||
{
|
||||
provide: () =>
|
||||
this.getAllItems().map((item) => ({
|
||||
name: item.label,
|
||||
action: () => this.selectItem(item),
|
||||
})),
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
category: "smart_action",
|
||||
hotkey: "alt+shift+x",
|
||||
isAvailable: () => !this.props.readonly && !this.props.isDisabled,
|
||||
}
|
||||
);
|
||||
useCommand(
|
||||
_t("Move to next %s", this.field.string),
|
||||
() => {
|
||||
const items = this.getAllItems();
|
||||
const nextIndex = items.findIndex((item) => item.isSelected) + 1;
|
||||
this.selectItem(items[nextIndex]);
|
||||
},
|
||||
{
|
||||
category: "smart_action",
|
||||
hotkey: "alt+x",
|
||||
isAvailable: () =>
|
||||
!this.props.readonly &&
|
||||
!this.props.isDisabled &&
|
||||
!this.getAllItems().at(-1).isSelected,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
get displayName() {
|
||||
return this.props.record.fields[this.props.name].string;
|
||||
}
|
||||
get type() {
|
||||
return this.props.record.fields[this.props.name].type;
|
||||
/**
|
||||
* @returns {{ selection?: [string, string][], string: string, type: "many2one" | "selection" }}
|
||||
*/
|
||||
get field() {
|
||||
return this.props.record.fields[this.props.name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines what items must be visible and how they must be displayed.
|
||||
* There are 4 main scenarios:
|
||||
*
|
||||
* 1. All items can be displayed inline, no modification in the UI;
|
||||
*
|
||||
* The following scenarios imply that the viewport is too small to display
|
||||
* all items in one line. Adjustments are made incrementally:
|
||||
*
|
||||
* 2. Items up to 1 before the currently selected item are combined in a dropdown;
|
||||
*
|
||||
* 3. Items up to 1 after the currently selected item are combined in a dropdown,
|
||||
* along with the initially folded items;
|
||||
*
|
||||
* 4. If that still doesn't suffice: all items are combined in a single dropdown.
|
||||
*/
|
||||
adjustVisibleItems() {
|
||||
// Get all visible buttons
|
||||
const itemEls = [
|
||||
...this.rootRef.el.querySelectorAll(".o_arrow_button:not(.dropdown-toggle)"),
|
||||
];
|
||||
const selectedIndex = itemEls.findIndex((el) =>
|
||||
el.classList.contains("o_arrow_button_current")
|
||||
);
|
||||
const itemsBefore = itemEls.slice(selectedIndex + 2).reverse();
|
||||
const itemsAfter = itemEls.slice(0, Math.max(selectedIndex - 1, 0)).reverse();
|
||||
|
||||
// Reset hidden elements
|
||||
show(...itemEls);
|
||||
hide(this.dropdownRef.el, this.beforeRef.el);
|
||||
if (this.items.folded.length) {
|
||||
show(this.afterRef.el);
|
||||
itemEls.forEach((el) => el.classList.remove("o_first"));
|
||||
} else {
|
||||
hide(this.afterRef.el);
|
||||
}
|
||||
|
||||
// Reset items variables
|
||||
this.items.before = [];
|
||||
this.items.after = [...this.items.folded];
|
||||
const itemsToAssign = this.getAllItems().filter((item) => !item.isFolded);
|
||||
|
||||
while (this.areItemsWrapping()) {
|
||||
if (itemsBefore.length) {
|
||||
// Case 1: elements before can be hidden
|
||||
show(this.beforeRef.el);
|
||||
hide(itemsBefore.shift());
|
||||
this.items.before.push(itemsToAssign.shift());
|
||||
} else if (itemsAfter.length) {
|
||||
// Case 2: elements before are hidden, elements after can be hidden
|
||||
show(this.afterRef.el);
|
||||
hide(itemsAfter.pop());
|
||||
this.items.after.unshift(itemsToAssign.pop());
|
||||
} else {
|
||||
// Last resort: no elements can be hidden => fallback to single dropdown
|
||||
show(this.dropdownRef.el);
|
||||
hide(this.beforeRef.el, this.afterRef.el, ...itemEls);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
areItemsWrapping() {
|
||||
const root = this.rootRef.el;
|
||||
const firstItem = root.querySelector(":scope > :not(.d-none)");
|
||||
if (!firstItem) {
|
||||
return false;
|
||||
}
|
||||
const { height: currentHeight } = root.getBoundingClientRect();
|
||||
const { height: targetHeight } = firstItem.getBoundingClientRect();
|
||||
return currentHeight > targetHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {StatusBarItem[]}
|
||||
*/
|
||||
getAllItems() {
|
||||
const { foldField, name, record } = this.props;
|
||||
const currentValue = record.data[name];
|
||||
if (this.field.type === "many2one") {
|
||||
// Many2one
|
||||
return this.specialData.data.map((option) => ({
|
||||
value: option.id,
|
||||
label: option.display_name,
|
||||
isFolded: option[foldField],
|
||||
isSelected: Boolean(currentValue && option.id === currentValue[0]),
|
||||
}));
|
||||
} else {
|
||||
// Selection
|
||||
let { selection } = this.field;
|
||||
const { visibleSelection } = this.props;
|
||||
if (visibleSelection?.length) {
|
||||
selection = selection.filter(
|
||||
([value]) => value === currentValue || visibleSelection.includes(value)
|
||||
);
|
||||
}
|
||||
return selection.map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
isFolded: false,
|
||||
isSelected: value === currentValue,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentLabel() {
|
||||
return this.getAllItems().find((item) => item.isSelected)?.label || _t("More");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {StatusBarItem} item
|
||||
*/
|
||||
getDropdownItemClassNames(item) {
|
||||
const classNames = [];
|
||||
if (item.isSelected) {
|
||||
@@ -162,78 +283,44 @@ export class StatusBarField extends Component {
|
||||
return classNames.join(" ");
|
||||
}
|
||||
|
||||
getVisibleMany2Ones() {
|
||||
let items = this.options;
|
||||
// FIXME: do this somewhere else
|
||||
items = items.map((i) => {
|
||||
return {
|
||||
id: i.id,
|
||||
name: i.display_name,
|
||||
isFolded: i[this.props.foldField],
|
||||
};
|
||||
});
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
isSelected:
|
||||
this.props.record.data[this.props.name] &&
|
||||
item.id === this.props.record.data[this.props.name][0],
|
||||
}));
|
||||
/**
|
||||
* @param {StatusBarItem} item
|
||||
*/
|
||||
getItemTooltip(item) {
|
||||
if (item.isSelected) {
|
||||
return _t("Current state");
|
||||
}
|
||||
if (this.props.isDisabled) {
|
||||
return _t("Not active state");
|
||||
}
|
||||
return _t("Not active state, click to change it");
|
||||
}
|
||||
|
||||
getVisibleSelection() {
|
||||
let selection = this.options;
|
||||
if (this.props.visibleSelection.length) {
|
||||
selection = selection.filter(
|
||||
(item) =>
|
||||
this.props.visibleSelection.includes(item[0]) ||
|
||||
item[0] === this.props.record.data[this.props.name]
|
||||
);
|
||||
}
|
||||
return selection.map((item) => ({
|
||||
id: item[0],
|
||||
name: item[1],
|
||||
isSelected: item[0] === this.props.record.data[this.props.name],
|
||||
isFolded: false,
|
||||
}));
|
||||
}
|
||||
|
||||
computeItems(grouped = true) {
|
||||
let items = null;
|
||||
if (this.props.record.fields[this.props.name].type === "many2one") {
|
||||
items = this.getVisibleMany2Ones();
|
||||
} else {
|
||||
items = this.getVisibleSelection();
|
||||
}
|
||||
if (!grouped) {
|
||||
return items;
|
||||
}
|
||||
|
||||
if (this.env.isSmall) {
|
||||
return {
|
||||
folded: items,
|
||||
unfolded: [],
|
||||
};
|
||||
} else {
|
||||
const groups = groupBy(items, (item) => item.isSelected || !item.isFolded);
|
||||
return {
|
||||
folded: groups.false || [],
|
||||
unfolded: groups.true || [],
|
||||
};
|
||||
}
|
||||
getSortedItems() {
|
||||
const before = [];
|
||||
const after = [];
|
||||
const { true: inline = [], false: folded = [] } = groupBy(
|
||||
this.getAllItems(),
|
||||
(item) => item.isSelected || !item.isFolded
|
||||
);
|
||||
inline.reverse(); // CSS rules account for this list to be reversed
|
||||
after.push(...folded);
|
||||
return { inline, before, after, folded };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {StatusBarItem} item
|
||||
*/
|
||||
async selectItem(item) {
|
||||
switch (this.props.record.fields[this.props.name].type) {
|
||||
case "many2one":
|
||||
await this.props.record.update({ [this.props.name]: [item.id, item.name] });
|
||||
break;
|
||||
case "selection":
|
||||
await this.props.record.update({ [this.props.name]: item.id });
|
||||
break;
|
||||
}
|
||||
return this.props.record.save();
|
||||
const { name, record } = this.props;
|
||||
const value = this.field.type === "many2one" ? [item.value, item.label] : item.value;
|
||||
await record.update({ [name]: value });
|
||||
await record.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {CustomEvent<{ payload: StatusBarItem }>} ev
|
||||
*/
|
||||
onDropdownItemSelected(ev) {
|
||||
this.selectItem(ev.detail.payload);
|
||||
}
|
||||
@@ -257,13 +344,10 @@ export const statusBarField = {
|
||||
},
|
||||
],
|
||||
supportedTypes: ["many2one", "selection"],
|
||||
isEmpty: (record, fieldName) => record.model.env.isSmall && !record.data[fieldName],
|
||||
isEmpty: (record, fieldName) => !record.data[fieldName],
|
||||
extractProps: ({ attrs, options, viewType }, dynamicInfo) => ({
|
||||
canCreate: Boolean(attrs.can_create),
|
||||
canWrite: Boolean(attrs.can_write),
|
||||
isDisabled: !options.clickable,
|
||||
visibleSelection:
|
||||
attrs.statusbar_visible && attrs.statusbar_visible.trim().split(/\s*,\s*/g),
|
||||
visibleSelection: attrs.statusbar_visible?.trim().split(/\s*,\s*/g),
|
||||
withCommand: viewType === "form",
|
||||
foldField: options.fold_field,
|
||||
domain: dynamicInfo.domain(),
|
||||
|
||||
@@ -9,23 +9,23 @@
|
||||
align-self: stretch;
|
||||
align-items: stretch;
|
||||
|
||||
> .o_arrow_button {
|
||||
> .o_arrow_button:not(.d-none) {
|
||||
position: relative;
|
||||
padding-left: $o-statusbar-arrow-width * 1.5;
|
||||
border-radius: 0;
|
||||
|
||||
&:first-child {
|
||||
&.o_first {
|
||||
@include border-end-radius($border-radius);
|
||||
padding-right: $o-horizontal-padding; // Compensate container padding
|
||||
overflow-x: hidden; // to prevent horizontal scroll due to last arrow
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
&.o_last {
|
||||
@include border-start-radius($border-radius);
|
||||
padding-left: $o-horizontal-padding;
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
&:not(.o_first) {
|
||||
&:before, &:after {
|
||||
@include o-position-absolute(-$border-width, -$o-statusbar-arrow-width);
|
||||
display: block;
|
||||
@@ -49,7 +49,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
&:disabled {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
cursor: default;
|
||||
@@ -61,7 +61,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.o_arrow_button_current.disabled, &:active:not(:first-child) {
|
||||
&.o_arrow_button_current:disabled, &:active:not(.o_first) {
|
||||
background-color: map-get($-btn-secondary-design, active-background);
|
||||
border-color: map-get($-btn-secondary-design, active-border);
|
||||
color: map-get($-btn-secondary-design, active-color);
|
||||
|
||||
@@ -1,68 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.StatusBarField">
|
||||
<div class="o_statusbar_status">
|
||||
<t t-set="items" t-value="computeItems()" />
|
||||
|
||||
<t t-if="items.folded.length">
|
||||
<button type="button" class="btn btn-secondary dropdown-toggle" t-att-class="{ 'o_arrow_button': !env.isSmall, 'btn-primary': env.isSmall }">
|
||||
<Dropdown toggler="'parent'">
|
||||
<t t-foreach="items.folded" t-as="item" t-key="item.id">
|
||||
<DropdownItem class="getDropdownItemClassNames(item)" onSelected="() => this.selectItem(item)">
|
||||
<t t-esc="item.name" />
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</Dropdown>
|
||||
<t t-if="env.isSmall">
|
||||
<t t-esc="currentName" />
|
||||
</t>
|
||||
<t t-else="">More</t>
|
||||
</button>
|
||||
<t t-name="web.StatusBarField.Dropdown" owl="1">
|
||||
<t t-esc="label" />
|
||||
<Dropdown toggler="'parent'">
|
||||
<t t-foreach="items" t-as="item" t-key="item.value">
|
||||
<DropdownItem
|
||||
class="getDropdownItemClassNames(item)"
|
||||
onSelected="() => this.selectItem(item)"
|
||||
>
|
||||
<span t-esc="item.label" />
|
||||
</DropdownItem>
|
||||
</t>
|
||||
|
||||
<t t-foreach="items.unfolded.reverse()" t-as="item" t-key="item.id">
|
||||
<t t-if="item.isSelected">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary o_arrow_button_current o_arrow_button disabled"
|
||||
disabled="disabled"
|
||||
role="radio"
|
||||
aria-label="Current state"
|
||||
aria-checked="true"
|
||||
aria-current="step"
|
||||
t-att-disabled="props.isDisabled"
|
||||
t-att-data-value="item.id"
|
||||
t-esc="item.name"
|
||||
/>
|
||||
</t>
|
||||
<t t-elif="props.isDisabled">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary o_arrow_button disabled"
|
||||
disabled="disabled"
|
||||
role="radio"
|
||||
aria-label="Not active state"
|
||||
aria-checked="false"
|
||||
t-att-disabled="props.isDisabled"
|
||||
t-att-data-value="item.id"
|
||||
t-esc="item.name"
|
||||
/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary o_arrow_button"
|
||||
role="radio"
|
||||
aria-label="Not active state, click to change it"
|
||||
aria-checked="false"
|
||||
t-att-data-value="item.id"
|
||||
t-esc="item.name"
|
||||
t-on-click="() => this.selectItem(item)"
|
||||
/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</Dropdown>
|
||||
</t>
|
||||
|
||||
<t t-name="web.StatusBarField" owl="1">
|
||||
<div class="o_statusbar_status" t-ref="root" role="radiogroup">
|
||||
<button
|
||||
t-ref="after"
|
||||
type="button"
|
||||
class="btn btn-secondary dropdown-toggle o_arrow_button o_first"
|
||||
>
|
||||
<t t-call="web.StatusBarField.Dropdown">
|
||||
<t t-set="label">...</t>
|
||||
<t t-set="items" t-value="items.after" />
|
||||
</t>
|
||||
</button>
|
||||
<t t-foreach="items.inline" t-as="item" t-key="item.value">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary o_arrow_button"
|
||||
t-att-class="{
|
||||
o_first: item_first,
|
||||
o_arrow_button_current: item.isSelected,
|
||||
o_last: item_last,
|
||||
}"
|
||||
t-att-disabled="props.isDisabled || item.isSelected"
|
||||
role="radio"
|
||||
t-att-aria-label="getItemTooltip(item)"
|
||||
t-att-aria-checked="item.isSelected.toString()"
|
||||
t-att-aria-current="item.isSelected and 'step'"
|
||||
t-att-data-value="item.value"
|
||||
t-esc="item.label"
|
||||
t-on-click="() => this.selectItem(item)"
|
||||
/>
|
||||
</t>
|
||||
<button
|
||||
t-ref="before"
|
||||
type="button"
|
||||
class="btn btn-secondary dropdown-toggle o_arrow_button o_last"
|
||||
>
|
||||
<t t-call="web.StatusBarField.Dropdown">
|
||||
<t t-set="label">...</t>
|
||||
<t t-set="items" t-value="items.before" />
|
||||
</t>
|
||||
</button>
|
||||
<button t-ref="dropdown" type="button" class="btn btn-secondary dropdown-toggle">
|
||||
<t t-call="web.StatusBarField.Dropdown">
|
||||
<t t-set="label" t-value="getCurrentLabel()" />
|
||||
<t t-set="items" t-value="getAllItems()" />
|
||||
</t>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { click, getFixture } from "@web/../tests/helpers/utils";
|
||||
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
|
||||
import { registerCleanup } from "@web/../tests/helpers/cleanup";
|
||||
|
||||
let fixture;
|
||||
let serverData;
|
||||
@@ -10,6 +11,8 @@ QUnit.module("Mobile Fields", ({ beforeEach }) => {
|
||||
beforeEach(() => {
|
||||
setupViewRegistries();
|
||||
fixture = getFixture();
|
||||
fixture.setAttribute("style", "width:100vw; height:100vh;");
|
||||
registerCleanup(() => fixture.removeAttribute("style"));
|
||||
serverData = {
|
||||
models: {
|
||||
partner: {
|
||||
@@ -20,6 +23,7 @@ QUnit.module("Mobile Fields", ({ beforeEach }) => {
|
||||
records: [
|
||||
{ id: 1, display_name: "first record", trululu: 4 },
|
||||
{ id: 2, display_name: "second record", trululu: 1 },
|
||||
{ id: 3, display_name: "third record" },
|
||||
{ id: 4, display_name: "aaa" },
|
||||
],
|
||||
},
|
||||
@@ -45,16 +49,9 @@ QUnit.module("Mobile Fields", ({ beforeEach }) => {
|
||||
`,
|
||||
});
|
||||
|
||||
assert.containsOnce(
|
||||
fixture,
|
||||
".o_statusbar_status > button",
|
||||
"should have only one visible status in mobile, the active one"
|
||||
);
|
||||
assert.containsOnce(
|
||||
fixture,
|
||||
".o_statusbar_status .dropdown",
|
||||
"should have a dropdown containing all status"
|
||||
);
|
||||
assert.containsN(fixture, ".o_statusbar_status .o_arrow_button:visible", 4);
|
||||
assert.containsOnce(fixture, ".o_statusbar_status .o_arrow_button.dropdown-toggle:visible");
|
||||
assert.containsOnce(fixture, ".o_statusbar_status .o_arrow_button.o_arrow_button_current");
|
||||
assert.containsNone(
|
||||
fixture,
|
||||
".o_statusbar_status .dropdown-menu",
|
||||
@@ -62,34 +59,18 @@ QUnit.module("Mobile Fields", ({ beforeEach }) => {
|
||||
);
|
||||
assert.strictEqual(
|
||||
fixture.querySelector(".o_statusbar_status button.dropdown-toggle").textContent.trim(),
|
||||
"aaa",
|
||||
"statusbar button should display current field value"
|
||||
"..."
|
||||
);
|
||||
|
||||
// open the dropdown
|
||||
await click(fixture, ".o_statusbar_status > button");
|
||||
await click(fixture, ".o_statusbar_status .dropdown-toggle.o_last");
|
||||
|
||||
assert.containsOnce(
|
||||
fixture,
|
||||
".o_statusbar_status .dropdown-menu",
|
||||
"dropdown should be visible"
|
||||
);
|
||||
assert.containsN(
|
||||
fixture,
|
||||
".o_statusbar_status .dropdown-menu .dropdown-item",
|
||||
3,
|
||||
"should have 3 status"
|
||||
);
|
||||
assert.containsN(
|
||||
fixture,
|
||||
".o_statusbar_status .dropdown-item.disabled",
|
||||
3,
|
||||
"all status should be disabled"
|
||||
);
|
||||
assert.hasClass(
|
||||
fixture.querySelector(".o_statusbar_status .dropdown-item:nth-child(3)"),
|
||||
"active",
|
||||
"active status should be set"
|
||||
);
|
||||
assert.containsOnce(fixture, ".o_statusbar_status .dropdown-menu .dropdown-item.disabled");
|
||||
});
|
||||
|
||||
QUnit.test("statusbar with no status on extra small screens", async (assert) => {
|
||||
@@ -112,50 +93,20 @@ QUnit.module("Mobile Fields", ({ beforeEach }) => {
|
||||
"o_field_empty",
|
||||
"statusbar widget should have class o_field_empty in edit"
|
||||
);
|
||||
assert.containsOnce(
|
||||
fixture,
|
||||
".o_statusbar_status button.dropdown-toggle",
|
||||
"statusbar widget should have a button"
|
||||
);
|
||||
assert.strictEqual(
|
||||
fixture.querySelector(".o_statusbar_status button.dropdown-toggle").textContent.trim(),
|
||||
"",
|
||||
"statusbar button shouldn't have text for null field value"
|
||||
);
|
||||
assert.containsOnce(fixture, ".o_statusbar_status button.dropdown-toggle:visible");
|
||||
assert.strictEqual($(".o_statusbar_status button.dropdown-toggle:visible").text(), "...");
|
||||
|
||||
await click(fixture, ".o_statusbar_status button.dropdown-toggle");
|
||||
assert.containsOnce(
|
||||
fixture,
|
||||
".o_statusbar_status .dropdown-menu",
|
||||
"statusbar widget should have a dropdown menu"
|
||||
);
|
||||
assert.containsN(
|
||||
fixture,
|
||||
".o_statusbar_status .dropdown-menu .dropdown-item",
|
||||
3,
|
||||
"statusbar widget dropdown menu should have 3 items"
|
||||
);
|
||||
await click($(".o_statusbar_status button.dropdown-toggle:visible")[0]);
|
||||
|
||||
assert.containsOnce(fixture, ".o_statusbar_status .dropdown-menu");
|
||||
assert.containsOnce(fixture, ".o_statusbar_status .dropdown-menu .dropdown-item");
|
||||
assert.strictEqual(
|
||||
fixture
|
||||
.querySelectorAll(".o_statusbar_status .dropdown-menu .dropdown-item")[0]
|
||||
.querySelector(".o_statusbar_status .dropdown-menu .dropdown-item")
|
||||
.textContent.trim(),
|
||||
"first record",
|
||||
"statusbar widget dropdown first item should display the first record display_name"
|
||||
);
|
||||
assert.strictEqual(
|
||||
fixture
|
||||
.querySelectorAll(".o_statusbar_status .dropdown-menu .dropdown-item")[1]
|
||||
.textContent.trim(),
|
||||
"second record",
|
||||
"statusbar widget dropdown second item should display the second record display_name"
|
||||
);
|
||||
assert.strictEqual(
|
||||
fixture
|
||||
.querySelectorAll(".o_statusbar_status .dropdown-menu .dropdown-item")[2]
|
||||
.textContent.trim(),
|
||||
"aaa",
|
||||
"statusbar widget dropdown third item should display the third record display_name"
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("clickable statusbar widget on mobile view", async (assert) => {
|
||||
@@ -173,37 +124,20 @@ QUnit.module("Mobile Fields", ({ beforeEach }) => {
|
||||
`,
|
||||
});
|
||||
|
||||
await click(fixture, ".o_statusbar_status .dropdown-toggle");
|
||||
assert.hasClass(
|
||||
fixture.querySelector(".o_statusbar_status .dropdown-menu .dropdown-item:nth-child(3)"),
|
||||
"active"
|
||||
);
|
||||
assert.hasClass(
|
||||
fixture.querySelector(".o_statusbar_status .dropdown-menu .dropdown-item:nth-child(3)"),
|
||||
"disabled"
|
||||
);
|
||||
// Open dropdown
|
||||
await click($(".o_statusbar_status .dropdown-toggle:visible")[0]);
|
||||
|
||||
assert.containsN(
|
||||
fixture,
|
||||
".o_statusbar_status .dropdown-item:not(.dropdown-toggle):not(.disabled):not(.active)",
|
||||
2,
|
||||
"other status should be active and not disabled"
|
||||
);
|
||||
assert.containsOnce(fixture, ".o_statusbar_status .dropdown-item");
|
||||
|
||||
await click(
|
||||
fixture.querySelector(
|
||||
".o_statusbar_status .dropdown-item:not(.dropdown-toggle):not(.disabled):not(.active)"
|
||||
)
|
||||
);
|
||||
await click(fixture, ".o_statusbar_status .dropdown-item");
|
||||
|
||||
await click(fixture, ".o_statusbar_status .dropdown-toggle");
|
||||
assert.hasClass(
|
||||
fixture.querySelector(".o_statusbar_status .dropdown-menu .dropdown-item:nth-child(1)"),
|
||||
"active"
|
||||
);
|
||||
assert.hasClass(
|
||||
fixture.querySelector(".o_statusbar_status .dropdown-menu .dropdown-item:nth-child(1)"),
|
||||
"disabled"
|
||||
);
|
||||
assert.strictEqual($(".o_arrow_button_current").text(), "first record");
|
||||
assert.containsN(fixture, ".o_statusbar_status .o_arrow_button:visible", 3);
|
||||
assert.containsOnce(fixture, ".o_statusbar_status .dropdown-toggle:visible");
|
||||
|
||||
// Open second dropdown
|
||||
await click($(".o_statusbar_status .dropdown-toggle:visible")[0]);
|
||||
|
||||
assert.containsN(fixture, ".o_statusbar_status .dropdown-item", 2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -248,10 +248,7 @@ QUnit.module("Fields", (hooks) => {
|
||||
target.querySelector(".o_statusbar_status button[data-value='4']"),
|
||||
"o_arrow_button_current"
|
||||
);
|
||||
assert.hasClass(
|
||||
target.querySelector(".o_statusbar_status button[data-value='4']"),
|
||||
"disabled"
|
||||
);
|
||||
assert.ok(target.querySelector(".o_statusbar_status button[data-value='4']").disabled);
|
||||
|
||||
const clickableButtons = target.querySelectorAll(
|
||||
".o_statusbar_status button.btn:not(.dropdown-toggle):not(:disabled):not(.o_arrow_button_current)"
|
||||
@@ -264,10 +261,7 @@ QUnit.module("Fields", (hooks) => {
|
||||
target.querySelector(".o_statusbar_status button[data-value='1']"),
|
||||
"o_arrow_button_current"
|
||||
);
|
||||
assert.hasClass(
|
||||
target.querySelector(".o_statusbar_status button[data-value='1']"),
|
||||
"disabled"
|
||||
);
|
||||
assert.ok(target.querySelector(".o_statusbar_status button[data-value='1']").disabled);
|
||||
});
|
||||
|
||||
QUnit.test("statusbar with no status", async function (assert) {
|
||||
@@ -286,9 +280,9 @@ QUnit.module("Fields", (hooks) => {
|
||||
});
|
||||
|
||||
assert.doesNotHaveClass(target.querySelector(".o_statusbar_status"), "o_field_empty");
|
||||
assert.strictEqual(
|
||||
target.querySelector(".o_statusbar_status").children.length,
|
||||
0,
|
||||
assert.containsNone(
|
||||
target,
|
||||
".o_statusbar_status > :not(.d-none)",
|
||||
"statusbar widget should be empty"
|
||||
);
|
||||
});
|
||||
@@ -435,15 +429,15 @@ QUnit.module("Fields", (hooks) => {
|
||||
</form>`,
|
||||
});
|
||||
|
||||
await click(target, ".o_statusbar_status .dropdown-toggle");
|
||||
await click(target, ".o_statusbar_status .dropdown-toggle:not(.d-none)");
|
||||
|
||||
const status = target.querySelectorAll(".o_statusbar_status");
|
||||
assert.containsOnce(status[0], ".dropdown-item.disabled");
|
||||
assert.containsOnce(status[status.length - 1], "button.disabled");
|
||||
assert.containsOnce(status[status.length - 1], "button:disabled");
|
||||
}
|
||||
);
|
||||
|
||||
QUnit.test("statusbar: choose an item from the 'More' menu", async function (assert) {
|
||||
QUnit.test("statusbar: choose an item from the folded menu", async function (assert) {
|
||||
patchWithCleanup(browser, {
|
||||
setTimeout: (fn) => fn(),
|
||||
});
|
||||
@@ -472,11 +466,11 @@ QUnit.module("Fields", (hooks) => {
|
||||
document
|
||||
.querySelector(".o_statusbar_status .dropdown-toggle.o_arrow_button")
|
||||
.textContent.trim(),
|
||||
"More",
|
||||
"...",
|
||||
"button has the correct text"
|
||||
);
|
||||
|
||||
await click(target, ".o_statusbar_status .dropdown-toggle");
|
||||
await click(target, ".o_statusbar_status .dropdown-toggle:not(.d-none)");
|
||||
await click(target, ".o-dropdown .dropdown-item");
|
||||
assert.strictEqual(
|
||||
target.querySelector("[aria-label='Current state']").textContent,
|
||||
@@ -510,10 +504,10 @@ QUnit.module("Fields", (hooks) => {
|
||||
},
|
||||
});
|
||||
|
||||
assert.containsN(target, ".o_statusbar_status button.disabled", 3);
|
||||
assert.containsN(target, ".o_statusbar_status button:disabled", 3);
|
||||
assert.strictEqual(rpcCount, 1, "should have done 1 search_read rpc");
|
||||
await editInput(target, ".o_field_widget[name='qux'] input", 9.5);
|
||||
assert.containsN(target, ".o_statusbar_status button.disabled", 2);
|
||||
assert.containsN(target, ".o_statusbar_status button:disabled", 2);
|
||||
assert.strictEqual(rpcCount, 2, "should have done 1 more search_read rpc");
|
||||
await editInput(target, ".o_field_widget[name='qux'] input", "hey");
|
||||
assert.strictEqual(rpcCount, 2, "should not have done 1 more search_read rpc");
|
||||
|
||||
@@ -5446,23 +5446,24 @@ QUnit.module("Views", (hooks) => {
|
||||
});
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_statusbar_status button",
|
||||
".o_statusbar_status button:not(.d-none)",
|
||||
"Must have only one statusbar button"
|
||||
);
|
||||
await editInput(target, "input#name_0.o_input", "xpad");
|
||||
assert.containsN(
|
||||
target,
|
||||
".o_statusbar_status button",
|
||||
".o_statusbar_status button:not(.d-none)",
|
||||
2,
|
||||
"Must have only two statusbar buttons"
|
||||
);
|
||||
await clickDiscard(target);
|
||||
assert.containsOnce(
|
||||
target,
|
||||
".o_statusbar_status button",
|
||||
".o_statusbar_status button:not(.d-none)",
|
||||
"Must have only one statusbar button"
|
||||
);
|
||||
const buttonText = target.querySelectorAll(".o_statusbar_status button")[0].textContent;
|
||||
const buttonText = target.querySelectorAll(".o_statusbar_status button:not(.d-none)")[0]
|
||||
.textContent;
|
||||
assert.strictEqual(buttonText, "xphone", "The statusbar button should be xphone");
|
||||
});
|
||||
|
||||
@@ -10040,7 +10041,7 @@ QUnit.module("Views", (hooks) => {
|
||||
target.querySelector('button[data-value="4"]'),
|
||||
"o_arrow_button_current"
|
||||
);
|
||||
assert.hasClass(target.querySelector('button[data-value="4"]'), "disabled");
|
||||
assert.ok(target.querySelector('button[data-value="4"]').disabled);
|
||||
|
||||
failFlag = true;
|
||||
await click(target.querySelector('button[data-value="1"]'));
|
||||
|
||||
@@ -498,7 +498,7 @@ stepUtils.autoExpandMoreButtons('.o_form_saved'),
|
||||
position: "bottom",
|
||||
}, {
|
||||
mobile: true,
|
||||
trigger: ".o_statusbar_status .btn.dropdown-toggle",
|
||||
trigger: ".o_statusbar_status .dropdown-toggle:visible",
|
||||
content: _t("Change status from New to proposition."),
|
||||
position: "bottom",
|
||||
}, {
|
||||
@@ -594,7 +594,7 @@ stepUtils.autoExpandMoreButtons('.o_form_saved'),
|
||||
content: _t('Save & Close'),
|
||||
position: 'right',
|
||||
},
|
||||
...stepUtils.statusbarButtonsSteps('Send by Email', _t("Try to send it to email"), ".o_statusbar_status .btn:contains('Quotation')"),
|
||||
...stepUtils.statusbarButtonsSteps('Send by Email', _t("Try to send it to email"), ".o_statusbar_status .dropdown-toggle:contains('Quotation')"),
|
||||
{
|
||||
trigger: ".o_field_widget[name=email] input",
|
||||
content: _t("Enter an email address"),
|
||||
@@ -699,8 +699,8 @@ stepUtils.openBurgerMenu(".o_breadcrumb .active:contains('OP/')"),
|
||||
position: 'bottom',
|
||||
},
|
||||
...stepUtils.statusbarButtonsSteps('Confirm Order', _t("Confirm quotation")),
|
||||
...stepUtils.statusbarButtonsSteps('Receive Products', _t("Receive Product"), ".o_statusbar_status .btn.dropdown-toggle:contains('Purchase Order')"),
|
||||
...stepUtils.statusbarButtonsSteps('Validate', _t("Validate"), ".o_statusbar_status .btn.dropdown-toggle:contains('Ready')"),
|
||||
...stepUtils.statusbarButtonsSteps('Receive Products', _t("Receive Product"), ".o_statusbar_status .dropdown-toggle:contains('Purchase Order')"),
|
||||
...stepUtils.statusbarButtonsSteps('Validate', _t("Validate"), ".o_statusbar_status .dropdown-toggle:contains('Ready')"),
|
||||
{
|
||||
trigger: ".modal-footer .btn-primary",
|
||||
extra_trigger: ".modal-dialog",
|
||||
@@ -711,15 +711,15 @@ stepUtils.openBurgerMenu(".o_breadcrumb .active:contains('OP/')"),
|
||||
content: _t('go back to the purchase order'),
|
||||
position: 'bottom',
|
||||
},
|
||||
...stepUtils.statusbarButtonsSteps('Create Bill', _t('go to Vendor Bills'), ".o_statusbar_status .btn.dropdown-toggle:contains('Purchase Order')"),
|
||||
...stepUtils.statusbarButtonsSteps('Create Bill', _t('go to Vendor Bills'), ".o_statusbar_status .dropdown-toggle:contains('Purchase Order')"),
|
||||
{
|
||||
trigger:".o_field_widget[name=invoice_date] input",
|
||||
extra_trigger: ".o_form_label .o_field_widget:contains('Vendor Bill')",
|
||||
content: _t('Set the invoice date'),
|
||||
run: "text 01/01/2020",
|
||||
},
|
||||
...stepUtils.statusbarButtonsSteps('Confirm', _t("Try to send it to email"), ".o_statusbar_status .btn.dropdown-toggle:contains('Draft')"),
|
||||
...stepUtils.statusbarButtonsSteps('Register Payment', _t("Register Payment"), ".o_statusbar_status .btn.dropdown-toggle:contains('Posted')"),
|
||||
...stepUtils.statusbarButtonsSteps('Confirm', _t("Try to send it to email"), ".o_statusbar_status .o_arrow_button_current:contains('Draft')"),
|
||||
...stepUtils.statusbarButtonsSteps('Register Payment', _t("Register Payment"), ".o_statusbar_status .o_arrow_button_current:contains('Posted')"),
|
||||
{
|
||||
trigger: ".modal-footer .btn-primary",
|
||||
content: _t("Validate"),
|
||||
@@ -770,7 +770,7 @@ stepUtils.openBurgerMenu(".o_breadcrumb .active:contains('Manufacturing Orders')
|
||||
run: "text 1",
|
||||
},
|
||||
|
||||
...stepUtils.statusbarButtonsSteps('Produce All', _t("Produce All"), ".o_statusbar_status .btn.dropdown-toggle:contains('To Close')"),
|
||||
...stepUtils.statusbarButtonsSteps('Produce All', _t("Produce All"), ".o_statusbar_status .dropdown-toggle:contains('To Close')"),
|
||||
{
|
||||
edition: "enterprise",
|
||||
trigger: '.o_menu_toggle',
|
||||
@@ -869,7 +869,7 @@ stepUtils.mobileModifier(stepUtils.autoExpandMoreButtons('.o_control_panel .o_br
|
||||
position: "bottom",
|
||||
},
|
||||
...stepUtils.statusbarButtonsSteps('Confirm', _t("Validate"), ".o_breadcrumb .active:contains('Draft Invoice')"),
|
||||
...stepUtils.statusbarButtonsSteps('Register Payment', _t("Register Payment"), ".o_statusbar_status .btn.dropdown-toggle:contains('Posted')"),
|
||||
...stepUtils.statusbarButtonsSteps('Register Payment', _t("Register Payment"), ".o_statusbar_status .o_arrow_button_current:contains('Posted')"),
|
||||
{
|
||||
trigger: ".modal-footer .btn-primary",
|
||||
content: _t("Validate"),
|
||||
|
||||
Reference in New Issue
Block a user