diff --git a/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.js b/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.js index f2cb2f8e277..eea4bac70a6 100644 --- a/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.js +++ b/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.js @@ -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, -}); diff --git a/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.xml b/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.xml index d81733d7e44..73ea5334e67 100644 --- a/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.xml +++ b/addons/mail/static/src/views/fields/statusbar_duration/statusbar_duration_field.xml @@ -1,46 +1,32 @@ + - - - - - + + + - + - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + mail.StatusBarDurationField.Dropdown + + + + + + + + + + + + diff --git a/addons/purchase/static/src/js/tours/purchase.js b/addons/purchase/static/src/js/tours/purchase.js index f335827f7a7..72b95e686fa 100644 --- a/addons/purchase/static/src/js/tours/purchase.js +++ b/addons/purchase/static/src/js/tours/purchase.js @@ -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(), + ], +}); diff --git a/addons/web/static/src/views/fields/relational_utils.js b/addons/web/static/src/views/fields/relational_utils.js index 629cbfca16d..85bf48e9e07 100644 --- a/addons/web/static/src/views/fields/relational_utils.js +++ b/addons/web/static/src/views/fields/relational_utils.js @@ -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"]) => Promise} 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 }} */ + const result = useState({ data: {} }); useRecordObserver(async (record, props) => { result.data = await loadFn(ormWithCache, { ...props, record }); }); diff --git a/addons/web/static/src/views/fields/statusbar/statusbar_field.js b/addons/web/static/src/views/fields/statusbar/statusbar_field.js index 3dede26e29b..a1eb553eb27 100644 --- a/addons/web/static/src/views/fields/statusbar/statusbar_field.js +++ b/addons/web/static/src/views/fields/statusbar/statusbar_field.js @@ -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} */ 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(), diff --git a/addons/web/static/src/views/fields/statusbar/statusbar_field.scss b/addons/web/static/src/views/fields/statusbar/statusbar_field.scss index c5dacb22e26..13a67840f95 100644 --- a/addons/web/static/src/views/fields/statusbar/statusbar_field.scss +++ b/addons/web/static/src/views/fields/statusbar/statusbar_field.scss @@ -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); diff --git a/addons/web/static/src/views/fields/statusbar/statusbar_field.xml b/addons/web/static/src/views/fields/statusbar/statusbar_field.xml index bef98fc085e..6788a12f4ec 100644 --- a/addons/web/static/src/views/fields/statusbar/statusbar_field.xml +++ b/addons/web/static/src/views/fields/statusbar/statusbar_field.xml @@ -1,68 +1,66 @@ - - - - - - - - - - - - - - - - - - More - + + + + + + + - - - - - - - - - - - - - + + + + + + ... + + + + + + + + + ... + + + + + + + + + + + diff --git a/addons/web/static/tests/mobile/views/fields/statusbar_field_tests.js b/addons/web/static/tests/mobile/views/fields/statusbar_field_tests.js index ef436857d69..fc67c1c8ae0 100644 --- a/addons/web/static/tests/mobile/views/fields/statusbar_field_tests.js +++ b/addons/web/static/tests/mobile/views/fields/statusbar_field_tests.js @@ -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); }); }); diff --git a/addons/web/static/tests/views/fields/statusbar_field_tests.js b/addons/web/static/tests/views/fields/statusbar_field_tests.js index d936675e61c..494ddfd8837 100644 --- a/addons/web/static/tests/views/fields/statusbar_field_tests.js +++ b/addons/web/static/tests/views/fields/statusbar_field_tests.js @@ -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) => { `, }); - 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"); diff --git a/addons/web/static/tests/views/form/form_view_tests.js b/addons/web/static/tests/views/form/form_view_tests.js index cef6015a822..fe1d3df7d67 100644 --- a/addons/web/static/tests/views/form/form_view_tests.js +++ b/addons/web/static/tests/views/form/form_view_tests.js @@ -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"]')); diff --git a/odoo/addons/test_main_flows/static/tests/tours/main_flow.js b/odoo/addons/test_main_flows/static/tests/tours/main_flow.js index ba3252d397b..ea443295243 100644 --- a/odoo/addons/test_main_flows/static/tests/tours/main_flow.js +++ b/odoo/addons/test_main_flows/static/tests/tours/main_flow.js @@ -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"),