[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:
Julien Mougenot
2023-10-16 10:39:53 +00:00
parent b11b09405b
commit 1c56280382
11 changed files with 546 additions and 539 deletions
@@ -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,
});
@@ -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>
+122 -96
View File
@@ -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"),