diff --git a/cmd/odoo-server/main.go b/cmd/odoo-server/main.go index 67abac2..57a8a6f 100644 --- a/cmd/odoo-server/main.go +++ b/cmd/odoo-server/main.go @@ -12,6 +12,7 @@ import ( "log" "os" "os/signal" + "path/filepath" "syscall" "github.com/jackc/pgx/v5/pgxpool" @@ -47,6 +48,26 @@ func main() { cfg := tools.DefaultConfig() cfg.LoadFromEnv() + // Auto-detect frontend/ directory relative to the binary if not set + if cfg.FrontendDir == "" { + exe, _ := os.Executable() + candidate := filepath.Join(filepath.Dir(exe), "frontend") + if _, err := os.Stat(candidate); err != nil { + // Try relative to working directory + candidate = "frontend" + } + cfg.FrontendDir = candidate + } + // Auto-detect build/ directory + if cfg.BuildDir == "" { + exe, _ := os.Executable() + candidate := filepath.Join(filepath.Dir(exe), "build") + if _, err := os.Stat(candidate); err != nil { + candidate = "build" + } + cfg.BuildDir = candidate + } + log.Printf("odoo: Odoo Go Server 19.0") log.Printf("odoo: database: %s@%s:%d/%s", cfg.DBUser, cfg.DBHost, cfg.DBPort, cfg.DBName) @@ -87,6 +108,12 @@ func main() { log.Fatalf("odoo: schema init failed: %v", err) } + // Migrate schema: add any missing columns for newly registered fields + log.Println("odoo: running schema migration...") + if err := service.MigrateSchema(ctx, pool); err != nil { + log.Printf("odoo: schema migration warning: %v", err) + } + // Check if setup is needed (first boot) if service.NeedsSetup(ctx, pool) { log.Println("odoo: database is empty — setup wizard will be shown at /web/setup") diff --git a/frontend/account/static/demo/bank_opening_statement.pdf b/frontend/account/static/demo/bank_opening_statement.pdf new file mode 100644 index 0000000..362cec2 Binary files /dev/null and b/frontend/account/static/demo/bank_opening_statement.pdf differ diff --git a/frontend/account/static/demo/bank_statement_one_month_old.pdf b/frontend/account/static/demo/bank_statement_one_month_old.pdf new file mode 100644 index 0000000..ff736dd Binary files /dev/null and b/frontend/account/static/demo/bank_statement_one_month_old.pdf differ diff --git a/frontend/account/static/demo/in_invoice_yourcompany_demo_1.pdf b/frontend/account/static/demo/in_invoice_yourcompany_demo_1.pdf new file mode 100644 index 0000000..6e44cfb Binary files /dev/null and b/frontend/account/static/demo/in_invoice_yourcompany_demo_1.pdf differ diff --git a/frontend/account/static/demo/in_invoice_yourcompany_demo_2.pdf b/frontend/account/static/demo/in_invoice_yourcompany_demo_2.pdf new file mode 100644 index 0000000..a89f1ab Binary files /dev/null and b/frontend/account/static/demo/in_invoice_yourcompany_demo_2.pdf differ diff --git a/frontend/account/static/description/icon.png b/frontend/account/static/description/icon.png new file mode 100644 index 0000000..dd2104e Binary files /dev/null and b/frontend/account/static/description/icon.png differ diff --git a/frontend/account/static/description/icon.svg b/frontend/account/static/description/icon.svg new file mode 100644 index 0000000..beca415 --- /dev/null +++ b/frontend/account/static/description/icon.svg @@ -0,0 +1 @@ + diff --git a/frontend/account/static/description/icon_hi.png b/frontend/account/static/description/icon_hi.png new file mode 100644 index 0000000..e967f65 Binary files /dev/null and b/frontend/account/static/description/icon_hi.png differ diff --git a/frontend/account/static/description/l10n.png b/frontend/account/static/description/l10n.png new file mode 100644 index 0000000..e63103f Binary files /dev/null and b/frontend/account/static/description/l10n.png differ diff --git a/frontend/account/static/description/l10n.svg b/frontend/account/static/description/l10n.svg new file mode 100644 index 0000000..9979219 --- /dev/null +++ b/frontend/account/static/description/l10n.svg @@ -0,0 +1 @@ + diff --git a/frontend/account/static/src/components/account_batch_sending_summary/account_batch_sending_summary.js b/frontend/account/static/src/components/account_batch_sending_summary/account_batch_sending_summary.js new file mode 100644 index 0000000..a8997a2 --- /dev/null +++ b/frontend/account/static/src/components/account_batch_sending_summary/account_batch_sending_summary.js @@ -0,0 +1,21 @@ +import {Component} from "@odoo/owl"; +import {registry} from "@web/core/registry"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; + +export class AccountBatchSendingSummary extends Component { + static template = "account.BatchSendingSummary"; + static props = { + ...standardFieldProps, + }; + + setup() { + super.setup(); + this.data = this.props.record.data[this.props.name]; + } +} + +export const accountBatchSendingSummary = { + component: AccountBatchSendingSummary, +} + +registry.category("fields").add("account_batch_sending_summary", accountBatchSendingSummary); diff --git a/frontend/account/static/src/components/account_batch_sending_summary/account_batch_sending_summary.xml b/frontend/account/static/src/components/account_batch_sending_summary/account_batch_sending_summary.xml new file mode 100644 index 0000000..cc5f40e --- /dev/null +++ b/frontend/account/static/src/components/account_batch_sending_summary/account_batch_sending_summary.xml @@ -0,0 +1,15 @@ + + diff --git a/frontend/account/static/src/components/account_file_uploader/account_file_uploader.js b/frontend/account/static/src/components/account_file_uploader/account_file_uploader.js new file mode 100644 index 0000000..cc9d048 --- /dev/null +++ b/frontend/account/static/src/components/account_file_uploader/account_file_uploader.js @@ -0,0 +1,48 @@ +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { DocumentFileUploader } from "../document_file_uploader/document_file_uploader"; + +export class AccountFileUploader extends DocumentFileUploader { + static template = "account.AccountFileUploader"; + static props = { + ...DocumentFileUploader.props, + btnClass: { type: String, optional: true }, + linkText: { type: String, optional: true }, + togglerTemplate: { type: String, optional: true }, + }; + + getExtraContext() { + const extraContext = super.getExtraContext(); + const record_data = this.props.record ? this.props.record.data : false; + return record_data ? { + ...extraContext, + default_journal_id: record_data.id, + default_move_type: ( + (record_data.type === 'sale' && 'out_invoice') + || (record_data.type === 'purchase' && 'in_invoice') + || 'entry' + ), + } : extraContext; + + } + + getResModel() { + return "account.journal"; + } +} + +//when file uploader is used on account.journal (with a record) +export const accountFileUploader = { + component: AccountFileUploader, + extractProps: ({ attrs }) => ({ + togglerTemplate: attrs.template || "account.JournalUploadLink", + btnClass: attrs.btnClass || "", + linkText: attrs.title || _t("Upload"), + }), + fieldDependencies: [ + { name: "id", type: "integer" }, + { name: "type", type: "selection" }, + ], +}; + +registry.category("view_widgets").add("account_file_uploader", accountFileUploader); diff --git a/frontend/account/static/src/components/account_file_uploader/account_file_uploader.scss b/frontend/account/static/src/components/account_file_uploader/account_file_uploader.scss new file mode 100644 index 0000000..ec551da --- /dev/null +++ b/frontend/account/static/src/components/account_file_uploader/account_file_uploader.scss @@ -0,0 +1,7 @@ +.o_widget_account_file_uploader { + button.oe_kanban_action { + a { + color: var(--btn-color); + } + } +} diff --git a/frontend/account/static/src/components/account_file_uploader/account_file_uploader.xml b/frontend/account/static/src/components/account_file_uploader/account_file_uploader.xml new file mode 100644 index 0000000..41fcd3d --- /dev/null +++ b/frontend/account/static/src/components/account_file_uploader/account_file_uploader.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/account/static/src/components/account_merge_wizard_line_one2many/account_merge_wizard_line_one2many.js b/frontend/account/static/src/components/account_merge_wizard_line_one2many/account_merge_wizard_line_one2many.js new file mode 100644 index 0000000..70e6f9a --- /dev/null +++ b/frontend/account/static/src/components/account_merge_wizard_line_one2many/account_merge_wizard_line_one2many.js @@ -0,0 +1,61 @@ +import { registry } from "@web/core/registry"; +import { + SectionAndNoteListRenderer, + SectionAndNoteFieldOne2Many, + sectionAndNoteFieldOne2Many, +} from "../section_and_note_fields_backend/section_and_note_fields_backend"; + +export class AccountMergeWizardLinesRenderer extends SectionAndNoteListRenderer { + setup() { + super.setup(); + this.titleField = "info"; + } + + getCellClass(column, record) { + const classNames = super.getCellClass(column, record); + // Even though the `is_selected` field is invisible for section lines, we should + // keep its column (which would be hidden by the call to super.getCellClass) + // in order to align the section header name with the account names. + if (this.isSectionOrNote(record) && column.name === "is_selected") { + return classNames.replace(" o_hidden", ""); + } + return classNames; + } + + /** @override **/ + getSectionColumns(columns) { + const sectionCols = columns.filter( + (col) => + col.type === "field" && (col.name === this.titleField || col.name === "is_selected") + ); + return sectionCols.map((col) => { + if (col.name === this.titleField) { + return { ...col, colspan: columns.length - sectionCols.length + 1 }; + } else { + return { ...col }; + } + }); + } + + /** @override */ + isSortable(column) { + // Don't allow sorting columns, as that doesn't make sense in the wizard view. + return false; + } +} + +export class AccountMergeWizardLinesOne2Many extends SectionAndNoteFieldOne2Many { + static components = { + ...SectionAndNoteFieldOne2Many.components, + ListRenderer: AccountMergeWizardLinesRenderer, + }; +} + +export const accountMergeWizardLinesOne2Many = { + ...sectionAndNoteFieldOne2Many, + component: AccountMergeWizardLinesOne2Many, +}; + +registry + .category("fields") + .add("account_merge_wizard_lines_one2many", accountMergeWizardLinesOne2Many); diff --git a/frontend/account/static/src/components/account_move_form/account_move_form.js b/frontend/account/static/src/components/account_move_form/account_move_form.js new file mode 100644 index 0000000..bf7c706 --- /dev/null +++ b/frontend/account/static/src/components/account_move_form/account_move_form.js @@ -0,0 +1,94 @@ +import { registry } from "@web/core/registry"; +import { createElement, append } from "@web/core/utils/xml"; +import { Notebook } from "@web/core/notebook/notebook"; +import { formView } from "@web/views/form/form_view"; +import { FormCompiler } from "@web/views/form/form_compiler"; +import { FormRenderer } from "@web/views/form/form_renderer"; +import { FormController } from '@web/views/form/form_controller'; +import { useService } from "@web/core/utils/hooks"; +import { deleteConfirmationMessage } from "@web/core/confirmation_dialog/confirmation_dialog"; +import {_t} from "@web/core/l10n/translation"; + + +export class AccountMoveFormController extends FormController { + setup() { + super.setup(); + this.account_move_service = useService("account_move"); + } + + get cogMenuProps() { + return { + ...super.cogMenuProps, + printDropdownTitle: _t("Print"), + loadExtraPrintItems: this.loadExtraPrintItems.bind(this), + }; + } + + async loadExtraPrintItems() { + const items = await this.orm.call("account.move", "get_extra_print_items", [this.model.root.resId]); + return items.filter((item) => item.key !== "download_all"); + } + + + async deleteRecord() { + const deleteConfirmationDialogProps = this.deleteConfirmationDialogProps; + deleteConfirmationDialogProps.body = await this.account_move_service.getDeletionDialogBody(deleteConfirmationMessage, this.model.root.resId); + this.deleteRecordsWithConfirmation(deleteConfirmationDialogProps, [this.model.root]); + } +} + +export class AccountMoveFormNotebook extends Notebook { + static template = "account.AccountMoveFormNotebook"; + static props = { + ...Notebook.props, + onBeforeTabSwitch: { type: Function, optional: true }, + }; + + async changeTabTo(page_id) { + if (this.props.onBeforeTabSwitch) { + await this.props.onBeforeTabSwitch(page_id); + } + this.state.currentPage = page_id; + } +} + +export class AccountMoveFormRenderer extends FormRenderer { + static components = { + ...FormRenderer.components, + AccountMoveFormNotebook: AccountMoveFormNotebook, + }; + + async saveBeforeTabChange() { + if (this.props.record.isInEdition && await this.props.record.isDirty()) { + const contentEl = document.querySelector('.o_content'); + const scrollPos = contentEl.scrollTop; + await this.props.record.save(); + if (scrollPos) { + contentEl.scrollTop = scrollPos; + } + } + } +} + +export class AccountMoveFormCompiler extends FormCompiler { + compileNotebook(el, params) { + const originalNoteBook = super.compileNotebook(...arguments); + const noteBook = createElement("AccountMoveFormNotebook"); + for (const attr of originalNoteBook.attributes) { + noteBook.setAttribute(attr.name, attr.value); + } + noteBook.setAttribute("onBeforeTabSwitch", "() => __comp__.saveBeforeTabChange()"); + const slots = originalNoteBook.childNodes; + append(noteBook, [...slots]); + return noteBook; + } +} + +export const AccountMoveFormView = { + ...formView, + Renderer: AccountMoveFormRenderer, + Compiler: AccountMoveFormCompiler, + Controller: AccountMoveFormController, +}; + +registry.category("views").add("account_move_form", AccountMoveFormView); diff --git a/frontend/account/static/src/components/account_move_form/account_move_form_notebook.xml b/frontend/account/static/src/components/account_move_form/account_move_form_notebook.xml new file mode 100644 index 0000000..19ba6a8 --- /dev/null +++ b/frontend/account/static/src/components/account_move_form/account_move_form_notebook.xml @@ -0,0 +1,10 @@ + + + + + + () => this.changeTabTo(navItem[0]) + -1 + + + diff --git a/frontend/account/static/src/components/account_payment_field/account_payment.xml b/frontend/account/static/src/components/account_payment_field/account_payment.xml new file mode 100644 index 0000000..6f6ca53 --- /dev/null +++ b/frontend/account/static/src/components/account_payment_field/account_payment.xml @@ -0,0 +1,120 @@ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+ + + Add + + + + + Reversed on + Paid on + + + + + + + + Exchange Difference + + + + + +
+
+
+
+ + + + + +
diff --git a/frontend/account/static/src/components/account_payment_field/account_payment_field.js b/frontend/account/static/src/components/account_payment_field/account_payment_field.js new file mode 100644 index 0000000..84e8b33 --- /dev/null +++ b/frontend/account/static/src/components/account_payment_field/account_payment_field.js @@ -0,0 +1,84 @@ +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { usePopover } from "@web/core/popover/popover_hook"; +import { useService } from "@web/core/utils/hooks"; +import { localization } from "@web/core/l10n/localization"; +import { formatDate, deserializeDate } from "@web/core/l10n/dates"; + +import { formatMonetary } from "@web/views/fields/formatters"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; +import { Component } from "@odoo/owl"; + +class AccountPaymentPopOver extends Component { + static props = { "*": { optional: true } }; + static template = "account.AccountPaymentPopOver"; +} + +export class AccountPaymentField extends Component { + static props = { ...standardFieldProps }; + static template = "account.AccountPaymentField"; + + setup() { + const position = localization.direction === "rtl" ? "bottom" : "left"; + this.popover = usePopover(AccountPaymentPopOver, { position }); + this.orm = useService("orm"); + this.action = useService("action"); + } + + getInfo() { + const info = this.props.record.data[this.props.name] || { + content: [], + outstanding: false, + title: "", + move_id: this.props.record.resId, + }; + for (const [key, value] of Object.entries(info.content)) { + value.index = key; + value.amount_formatted = formatMonetary(value.amount, { + currencyId: value.currency_id, + }); + if (value.date) { + // value.date is a string, parse to date and format to the users date format + value.formattedDate = formatDate(deserializeDate(value.date)) + } + } + return { + lines: info.content, + outstanding: info.outstanding, + title: info.title, + moveId: info.move_id, + }; + } + + onInfoClick(ev, line) { + this.popover.open(ev.currentTarget, { + title: _t("Journal Entry Info"), + ...line, + _onRemoveMoveReconcile: this.removeMoveReconcile.bind(this), + _onOpenMove: this.openMove.bind(this), + }); + } + + async assignOutstandingCredit(moveId, id) { + await this.orm.call(this.props.record.resModel, 'js_assign_outstanding_line', [moveId, id], {}); + await this.props.record.model.root.load(); + } + + async removeMoveReconcile(moveId, partialId) { + this.popover.close(); + await this.orm.call(this.props.record.resModel, 'js_remove_outstanding_partial', [moveId, partialId], {}); + await this.props.record.model.root.load(); + } + + async openMove(moveId) { + const action = await this.orm.call(this.props.record.resModel, 'action_open_business_doc', [moveId], {}); + this.action.doAction(action); + } +} + +export const accountPaymentField = { + component: AccountPaymentField, + supportedTypes: ["binary"], +}; + +registry.category("fields").add("payment", accountPaymentField); diff --git a/frontend/account/static/src/components/account_payment_register_html/account_payment_register_html.js b/frontend/account/static/src/components/account_payment_register_html/account_payment_register_html.js new file mode 100644 index 0000000..ca958d3 --- /dev/null +++ b/frontend/account/static/src/components/account_payment_register_html/account_payment_register_html.js @@ -0,0 +1,23 @@ +import { Component } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; + +class AccountPaymentRegisterHtmlField extends Component { + static props = standardFieldProps; + static template = "account.AccountPaymentRegisterHtmlField"; + + get value() { + return this.props.record.data[this.props.name]; + } + + switchInstallmentsAmount(ev) { + if (ev.srcElement.classList.contains("installments_switch_button")) { + const root = this.env.model.root; + root.update({ amount: root.data.installments_switch_amount }); + } + } +} + +const accountPaymentRegisterHtmlField = { component: AccountPaymentRegisterHtmlField }; + +registry.category("fields").add("account_payment_register_html", accountPaymentRegisterHtmlField); diff --git a/frontend/account/static/src/components/account_payment_register_html/account_payment_register_html.xml b/frontend/account/static/src/components/account_payment_register_html/account_payment_register_html.xml new file mode 100644 index 0000000..33e689e --- /dev/null +++ b/frontend/account/static/src/components/account_payment_register_html/account_payment_register_html.xml @@ -0,0 +1,6 @@ + + + +
+ + diff --git a/frontend/account/static/src/components/account_payment_term_form/payment_term_line_ids.js b/frontend/account/static/src/components/account_payment_term_form/payment_term_line_ids.js new file mode 100644 index 0000000..84ac1b3 --- /dev/null +++ b/frontend/account/static/src/components/account_payment_term_form/payment_term_line_ids.js @@ -0,0 +1,25 @@ +import { registry } from "@web/core/registry"; + +import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field"; +import { useAddInlineRecord } from "@web/views/fields/relational_utils"; + +export class PaymentTermLineIdsOne2Many extends X2ManyField { + setup() { + super.setup(); + // Overloads the addInLine method to mark all new records as 'dirty' by calling update with an empty object. + // This prevents the records from being abandoned if the user clicks globally or on an existing record. + this.addInLine = useAddInlineRecord({ + addNew: async (...args) => { + const newRecord = await this.list.addNewRecord(...args); + newRecord.update({}); + } + }); + } +} + +export const PaymentTermLineIds = { + ...x2ManyField, + component: PaymentTermLineIdsOne2Many, +} + +registry.category("fields").add("payment_term_line_ids", PaymentTermLineIds); diff --git a/frontend/account/static/src/components/account_pick_currency_rate/account_pick_currency_rate.js b/frontend/account/static/src/components/account_pick_currency_rate/account_pick_currency_rate.js new file mode 100644 index 0000000..ed6f9ff --- /dev/null +++ b/frontend/account/static/src/components/account_pick_currency_rate/account_pick_currency_rate.js @@ -0,0 +1,44 @@ +import { Component } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { useDateTimePicker } from "@web/core/datetime/datetime_picker_hook"; +import { useService } from "@web/core/utils/hooks"; +import { today } from "@web/core/l10n/dates"; +import { standardWidgetProps } from "@web/views/widgets/standard_widget_props"; + + +export class AccountPickCurrencyDate extends Component { + static template = "account.AccountPickCurrencyDate"; + static props = { + ...standardWidgetProps, + record: { type: Object, optional: true }, + }; + + setup() { + this.orm = useService("orm"); + this.dateTimePicker = useDateTimePicker({ + target: 'datetime-picker-target', + onApply: async (date) => { + const record = this.props.record + const rate = await this.orm.call( + 'account.move', + 'get_currency_rate', + [record.resId, record.data.company_id.id, record.data.currency_id.id, date.toISODate()], + ); + this.props.record.update({ invoice_currency_rate: rate }); + await this.props.record.save(); + }, + get pickerProps() { + return { + type: 'date', + value: today(), + }; + }, + }); + } +} + +export const accountPickCurrencyDate = { + component: AccountPickCurrencyDate, +} + +registry.category("view_widgets").add("account_pick_currency_date", accountPickCurrencyDate); diff --git a/frontend/account/static/src/components/account_pick_currency_rate/account_pick_currency_rate.xml b/frontend/account/static/src/components/account_pick_currency_rate/account_pick_currency_rate.xml new file mode 100644 index 0000000..18befe8 --- /dev/null +++ b/frontend/account/static/src/components/account_pick_currency_rate/account_pick_currency_rate.xml @@ -0,0 +1,15 @@ + + diff --git a/frontend/account/static/src/components/account_resequence/account_resequence.xml b/frontend/account/static/src/components/account_resequence/account_resequence.xml new file mode 100644 index 0000000..2578c65 --- /dev/null +++ b/frontend/account/static/src/components/account_resequence/account_resequence.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + +
DateBeforeAfter
+
+ + + + + + + + + +
diff --git a/frontend/account/static/src/components/account_resequence/account_resequence_field.js b/frontend/account/static/src/components/account_resequence/account_resequence_field.js new file mode 100644 index 0000000..165aafa --- /dev/null +++ b/frontend/account/static/src/components/account_resequence/account_resequence_field.js @@ -0,0 +1,22 @@ +import { registry } from "@web/core/registry"; +import { Component } from "@odoo/owl"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; + +class ChangeLine extends Component { + static template = "account.ResequenceChangeLine"; + static props = ["changeLine", "ordering"]; +} + +class ShowResequenceRenderer extends Component { + static template = "account.ResequenceRenderer"; + static components = { ChangeLine }; + static props = { ...standardFieldProps }; + getValue() { + const value = this.props.record.data[this.props.name]; + return value ? JSON.parse(value) : { changeLines: [], ordering: "date" }; + } +} + +registry.category("fields").add("account_resequence_widget", { + component: ShowResequenceRenderer, +}); diff --git a/frontend/account/static/src/components/account_statusbar_secured/account_move_statusbar_secured.js b/frontend/account/static/src/components/account_statusbar_secured/account_move_statusbar_secured.js new file mode 100644 index 0000000..c71c95c --- /dev/null +++ b/frontend/account/static/src/components/account_statusbar_secured/account_move_statusbar_secured.js @@ -0,0 +1,25 @@ +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { statusBarField, StatusBarField } from "@web/views/fields/statusbar/statusbar_field"; + +export class AccountMoveStatusBarSecuredField extends StatusBarField { + static template = "account.MoveStatusBarSecuredField"; + + get isSecured() { + return this.props.record.data['secured']; + } + + get currentItem() { + return this.getAllItems().find((item) => item.isSelected); + } +} + +export const accountMoveStatusBarSecuredField = { + ...statusBarField, + component: AccountMoveStatusBarSecuredField, + displayName: _t("Status with secured indicator for Journal Entries"), + supportedTypes: ["selection"], + additionalClasses: ["o_field_statusbar"], +}; + +registry.category("fields").add("account_move_statusbar_secured", accountMoveStatusBarSecuredField); diff --git a/frontend/account/static/src/components/account_statusbar_secured/account_move_statusbar_secured.xml b/frontend/account/static/src/components/account_statusbar_secured/account_move_statusbar_secured.xml new file mode 100644 index 0000000..ce22e9c --- /dev/null +++ b/frontend/account/static/src/components/account_statusbar_secured/account_move_statusbar_secured.xml @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + account.MoveStatusBarSecuredField.Dropdown + + + + + + + + + + + + + + + diff --git a/frontend/account/static/src/components/account_tax_repartition_line_factor_percent/account_tax_repartition_line_factor_percent.js b/frontend/account/static/src/components/account_tax_repartition_line_factor_percent/account_tax_repartition_line_factor_percent.js new file mode 100644 index 0000000..87998c5 --- /dev/null +++ b/frontend/account/static/src/components/account_tax_repartition_line_factor_percent/account_tax_repartition_line_factor_percent.js @@ -0,0 +1,54 @@ +import { FloatField, floatField } from "@web/views/fields/float/float_field"; +import { roundPrecision } from "@web/core/utils/numbers"; +import {registry} from "@web/core/registry"; + +export class AccountTaxRepartitionLineFactorPercent extends FloatField { + static defaultProps = { + ...FloatField.defaultProps, + digits: [16, 12], + }; + + /* + * @override + * We don't want to display all amounts with 12 digits behind so we remove the trailing 0 + * as much as possible. + */ + get formattedValue() { + const value = super.formattedValue; + const trailingNumbersMatch = value.match(/(\d+)$/); + if (!trailingNumbersMatch) { + return value; + } + const trailingZeroMatch = trailingNumbersMatch[1].match(/(0+)$/); + if (!trailingZeroMatch) { + return value; + } + const nbTrailingZeroToRemove = Math.min(trailingZeroMatch[1].length, trailingNumbersMatch[1].length - 2); + return value.substring(0, value.length - nbTrailingZeroToRemove); + } + + /* + * @override + * Prevent the users of showing a rounding at 12 digits on the screen but + * getting an unrounded value after typing "= 2/3" on the field when saving. + */ + parse(value) { + const parsedValue = super.parse(value); + try { + Number(parsedValue); + } catch { + return parsedValue; + } + const precisionRounding = Number(`1e-${this.props.digits[1]}`); + return roundPrecision(parsedValue, precisionRounding); + } +} + + +export const accountTaxRepartitionLineFactorPercent = { + ...floatField, + component: AccountTaxRepartitionLineFactorPercent, +}; + + +registry.category("fields").add("account_tax_repartition_line_factor_percent", accountTaxRepartitionLineFactorPercent); diff --git a/frontend/account/static/src/components/account_type_selection/account_type_selection.js b/frontend/account/static/src/components/account_type_selection/account_type_selection.js new file mode 100644 index 0000000..1a43a09 --- /dev/null +++ b/frontend/account/static/src/components/account_type_selection/account_type_selection.js @@ -0,0 +1,62 @@ +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { SelectionField, selectionField } from "@web/views/fields/selection/selection_field"; + +export class AccountTypeSelection extends SelectionField { + static template = "account.AccountTypeSelection"; + setup() { + super.setup(); + const getChoicesForGroup = (group) => { + return this.choices.filter(x => x.value.startsWith(group)); + } + this.sections = [ + { + label: _t('Balance Sheet'), + name: "balance_sheet" + }, + { + label: _t('Profit & Loss'), + name: "profit_and_loss" + }, + ] + this.groups = [ + { + label: _t('Assets'), + choices: getChoicesForGroup('asset'), + section: "balance_sheet", + }, + { + label: _t('Liabilities'), + choices: getChoicesForGroup('liability'), + section: "balance_sheet", + }, + { + label: _t('Equity'), + choices: getChoicesForGroup('equity'), + section: "balance_sheet", + }, + { + label: _t('Income'), + choices: getChoicesForGroup('income'), + section: "profit_and_loss", + }, + { + label: _t('Expense'), + choices: getChoicesForGroup('expense'), + section: "profit_and_loss", + }, + { + label: _t('Other'), + choices: getChoicesForGroup('off_balance'), + section: "profit_and_loss", + }, + ]; + } +} + +export const accountTypeSelection = { + ...selectionField, + component: AccountTypeSelection, +}; + +registry.category("fields").add("account_type_selection", accountTypeSelection); diff --git a/frontend/account/static/src/components/account_type_selection/account_type_selection.xml b/frontend/account/static/src/components/account_type_selection/account_type_selection.xml new file mode 100644 index 0000000..11bf302 --- /dev/null +++ b/frontend/account/static/src/components/account_type_selection/account_type_selection.xml @@ -0,0 +1,13 @@ + + + + + + + + groups + sections + + + + diff --git a/frontend/account/static/src/components/actionable_errors/actionable_errors.js b/frontend/account/static/src/components/actionable_errors/actionable_errors.js new file mode 100644 index 0000000..765b315 --- /dev/null +++ b/frontend/account/static/src/components/actionable_errors/actionable_errors.js @@ -0,0 +1,57 @@ +import { registry } from "@web/core/registry"; +import { Component } from "@odoo/owl"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; +import { useService } from "@web/core/utils/hooks"; + +const WARNING_TYPE_ORDER = ["danger", "warning", "info"]; + +export class ActionableErrors extends Component { + static props = { errorData: {type: Object} }; + static template = "account.ActionableErrors"; + + setup() { + super.setup(); + this.actionService = useService("action"); + this.orm = useService("orm"); + } + + get errorData() { + return this.props.errorData; + } + + async handleOnClick(errorData){ + if (errorData.action?.view_mode) { + // view_mode is not handled JS side + errorData.action['views'] = errorData.action.view_mode.split(',').map(mode => [false, mode]); + delete errorData.action['view_mode']; + } + if (errorData.action_call) { + const [model, method, args] = errorData.action_call; + await this.orm.call(model, method, [args]); + this.env.model.action.doAction("soft_reload"); + } else { + this.env.model.action.doAction(errorData.action); + } + } + + get sortedActionableErrors() { + return this.errorData && Object.fromEntries( + Object.entries(this.errorData).sort( + (a, b) => + WARNING_TYPE_ORDER.indexOf(a[1]["level"] || "warning") - + WARNING_TYPE_ORDER.indexOf(b[1]["level"] || "warning"), + ), + ); + } +} + +export class ActionableErrorsField extends ActionableErrors { + static props = { ...standardFieldProps }; + + get errorData() { + return this.props.record.data[this.props.name]; + } +} + +export const actionableErrorsField = {component: ActionableErrorsField}; +registry.category("fields").add("actionable_errors", actionableErrorsField); diff --git a/frontend/account/static/src/components/actionable_errors/actionable_errors.xml b/frontend/account/static/src/components/actionable_errors/actionable_errors.xml new file mode 100644 index 0000000..e403946 --- /dev/null +++ b/frontend/account/static/src/components/actionable_errors/actionable_errors.xml @@ -0,0 +1,26 @@ + + + + + + + + diff --git a/frontend/account/static/src/components/auto_save_res_partner_bank/auto_save_res_partner_bank.js b/frontend/account/static/src/components/auto_save_res_partner_bank/auto_save_res_partner_bank.js new file mode 100644 index 0000000..c2a94ea --- /dev/null +++ b/frontend/account/static/src/components/auto_save_res_partner_bank/auto_save_res_partner_bank.js @@ -0,0 +1,17 @@ +import { registry } from "@web/core/registry"; +import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field"; + + +export class AutoSaveResPartnerField extends X2ManyField { + async onAdd({ context, editable } = {}) { + await this.props.record.model.root.save(); + await super.onAdd({ context, editable }); + } +} + +export const autoSaveResPartnerField = { + ...x2ManyField, + component: AutoSaveResPartnerField, +}; + +registry.category("fields").add("auto_save_res_partner", autoSaveResPartnerField); diff --git a/frontend/account/static/src/components/autosave_many2many_tax_tags/autosave_many2many_tax_tags.js b/frontend/account/static/src/components/autosave_many2many_tax_tags/autosave_many2many_tax_tags.js new file mode 100644 index 0000000..7a1d394 --- /dev/null +++ b/frontend/account/static/src/components/autosave_many2many_tax_tags/autosave_many2many_tax_tags.js @@ -0,0 +1,53 @@ +import { registry } from "@web/core/registry"; +import { useRecordObserver } from "@web/model/relational_model/utils"; +import { + Many2ManyTaxTagsField, + many2ManyTaxTagsField +} from "@account/components/many2x_tax_tags/many2x_tax_tags"; + +export class AutosaveMany2ManyTaxTagsField extends Many2ManyTaxTagsField { + setup() { + super.setup(); + + this.lastBalance = this.props.record.data.balance; + this.lastAccount = this.props.record.data.account_id; + this.lastPartner = this.props.record.data.partner_id; + + const super_update = this.update; + this.update = (recordlist) => { + super_update(recordlist); + this._saveOnUpdate(); + }; + useRecordObserver(this.onRecordChange.bind(this)); + } + + async deleteTag(id) { + await super.deleteTag(id); + await this._saveOnUpdate(); + } + + onRecordChange(record) { + const line = record.data; + if (line.tax_ids.records.length > 0) { + if (line.balance !== this.lastBalance + || line.account_id.id !== this.lastAccount.id + || line.partner_id.id !== this.lastPartner.id) { + this.lastBalance = line.balance; + this.lastAccount = line.account_id; + this.lastPartner = line.partner_id; + return record.model.root.save(); + } + } + } + + async _saveOnUpdate() { + await this.props.record.model.root.save(); + } +} + +export const autosaveMany2ManyTaxTagsField = { + ...many2ManyTaxTagsField, + component: AutosaveMany2ManyTaxTagsField, +}; + +registry.category("fields").add("autosave_many2many_tax_tags", autosaveMany2ManyTaxTagsField); diff --git a/frontend/account/static/src/components/bill_guide/bill_guide.js b/frontend/account/static/src/components/bill_guide/bill_guide.js new file mode 100644 index 0000000..d99b8db --- /dev/null +++ b/frontend/account/static/src/components/bill_guide/bill_guide.js @@ -0,0 +1,69 @@ +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { DocumentFileUploader } from "../document_file_uploader/document_file_uploader"; + +import { Component, onWillStart } from "@odoo/owl"; + +export class BillGuide extends Component { + static template = "account.BillGuide"; + static components = { + DocumentFileUploader, + }; + static props = ["*"]; // could contain view_widget props + + setup() { + this.orm = useService("orm"); + this.action = useService("action"); + this.context = null; + this.alias = null; + this.showSampleAction = false; + onWillStart(this.onWillStart); + } + + async onWillStart() { + const rec = this.props.record; + const ctx = this.env.searchModel.context; + if (rec) { + // prepare context from journal record + this.context = { + default_journal_id: rec.resId, + default_move_type: (rec.data.type === 'sale' && 'out_invoice') || (rec.data.type === 'purchase' && 'in_invoice') || 'entry', + active_model: rec.resModel, + active_ids: [rec.resId], + } + this.alias = rec.data.alias_domain_id && rec.data.alias_id[1] || false; + } else if (!ctx?.default_journal_id && ctx?.active_id) { + this.context = { + default_journal_id: ctx.active_id, + } + } + this.showSampleAction = await this.orm.call("account.journal", "is_sample_action_available"); + } + + handleButtonClick(action, model="account.journal") { + this.action.doActionButton({ + resModel: model, + name: action, + context: this.context || this.env.searchModel.context, + type: 'object', + }); + } + + openVendorBill() { + return this.action.doAction({ + type: "ir.actions.act_window", + res_model: "account.move", + views: [[false, "form"]], + context: { + default_move_type: "in_invoice", + }, + }); + } +} + + +export const billGuide = { + component: BillGuide, +}; + +registry.category("view_widgets").add("bill_upload_guide", billGuide); diff --git a/frontend/account/static/src/components/bill_guide/bill_guide.scss b/frontend/account/static/src/components/bill_guide/bill_guide.scss new file mode 100644 index 0000000..c09ce84 --- /dev/null +++ b/frontend/account/static/src/components/bill_guide/bill_guide.scss @@ -0,0 +1,35 @@ +.o_view_nocontent { + .o_nocontent_help:has(> .bill_guide_container) { + min-width: 65vw; + } +} + +.bill_guide_container { + @include media-breakpoint-up(sm) { + min-width: 400px; + } + + .bill_guide_left, .bill_guide_right { + width: 45%; + } + + .separator_wrapper { + width: 10%; + } +} + +.bill-guide-img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.mb-9 { + margin-bottom: 9rem !important; +} + +.account_drag_drop_btn { + border-style: dashed !important; + border-color: $o-brand-primary; + background-color: mix($o-brand-primary, $o-view-background-color, 15%); +} diff --git a/frontend/account/static/src/components/bill_guide/bill_guide.xml b/frontend/account/static/src/components/bill_guide/bill_guide.xml new file mode 100644 index 0000000..4670e65 --- /dev/null +++ b/frontend/account/static/src/components/bill_guide/bill_guide.xml @@ -0,0 +1,82 @@ + + + +
+
+
+
+ +
+
+ + +
+
+
+
+
+
+
+ or +
+
+
+
+
+
+
+ Email bills +
+
+
+ Send a bill to + +
+ +
+
+ +
+
+ + + diff --git a/frontend/account/static/src/components/char_with_placeholder_field/char_with_placeholder_field.js b/frontend/account/static/src/components/char_with_placeholder_field/char_with_placeholder_field.js new file mode 100644 index 0000000..ef19f8a --- /dev/null +++ b/frontend/account/static/src/components/char_with_placeholder_field/char_with_placeholder_field.js @@ -0,0 +1,22 @@ +import { registry } from "@web/core/registry"; +import { CharField, charField } from "@web/views/fields/char/char_field"; + +// Ensure that in Hoot tests, this module is loaded after `@mail/js/onchange_on_keydown` +// (needed because that module patches `charField`). +import "@mail/js/onchange_on_keydown"; + +export class CharWithPlaceholderField extends CharField { + static template = "account.CharWithPlaceholderField"; + + /** Override **/ + get formattedValue() { + return super.formattedValue || this.props.placeholder; + } +} + +export const charWithPlaceholderField = { + ...charField, + component: CharWithPlaceholderField, +}; + +registry.category("fields").add("char_with_placeholder_field", charWithPlaceholderField); diff --git a/frontend/account/static/src/components/char_with_placeholder_field/char_with_placeholder_field.xml b/frontend/account/static/src/components/char_with_placeholder_field/char_with_placeholder_field.xml new file mode 100644 index 0000000..8d7b342 --- /dev/null +++ b/frontend/account/static/src/components/char_with_placeholder_field/char_with_placeholder_field.xml @@ -0,0 +1,10 @@ + + + + + + {'text-muted': !this.props.record.data[props.name]} + + + + diff --git a/frontend/account/static/src/components/char_with_placeholder_field_to_check/char_with_placeholder_field.xml b/frontend/account/static/src/components/char_with_placeholder_field_to_check/char_with_placeholder_field.xml new file mode 100644 index 0000000..691dbe1 --- /dev/null +++ b/frontend/account/static/src/components/char_with_placeholder_field_to_check/char_with_placeholder_field.xml @@ -0,0 +1,14 @@ + + + + + + + To review + + + + + diff --git a/frontend/account/static/src/components/char_with_placeholder_field_to_check/char_with_placeholder_field_to_check_to_check.js b/frontend/account/static/src/components/char_with_placeholder_field_to_check/char_with_placeholder_field_to_check_to_check.js new file mode 100644 index 0000000..dd271f1 --- /dev/null +++ b/frontend/account/static/src/components/char_with_placeholder_field_to_check/char_with_placeholder_field_to_check_to_check.js @@ -0,0 +1,16 @@ +import { registry } from "@web/core/registry"; +import { + charWithPlaceholderField, + CharWithPlaceholderField +} from "../char_with_placeholder_field/char_with_placeholder_field"; + +export class CharWithPlaceholderFieldToCheck extends CharWithPlaceholderField { + static template = "account.CharWithPlaceholderField"; +} + +export const charWithPlaceholderFieldToCheck = { + ...charWithPlaceholderField, + component: CharWithPlaceholderFieldToCheck, +}; + +registry.category("fields").add("char_with_placeholder_field_to_check", charWithPlaceholderFieldToCheck); diff --git a/frontend/account/static/src/components/currency_form/form_controller.js b/frontend/account/static/src/components/currency_form/form_controller.js new file mode 100644 index 0000000..d499ad3 --- /dev/null +++ b/frontend/account/static/src/components/currency_form/form_controller.js @@ -0,0 +1,43 @@ +/** @odoo-module **/ + +import { _t } from "@web/core/l10n/translation"; +import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; +import { registry } from "@web/core/registry"; +import { FormController } from "@web/views/form/form_controller"; +import { formView } from "@web/views/form/form_view"; + +export class CurrencyFormController extends FormController { + + async onWillSaveRecord(record) { + if (record.data.display_rounding_warning && + record._values.rounding !== undefined && + record.data.rounding < record._values.rounding + ) { + return new Promise((resolve) => { + this.dialogService.add(ConfirmationDialog, { + title: _t("Confirmation Warning"), + body: _t( + "You're about to permanently change the decimals for all prices in your database.\n" + + "This change cannot be undone without technical support." + ), + confirmLabel: _t("Confirm"), + cancelLabel: _t("Cancel"), + confirm: () => resolve(true), + cancel: () => { + record.discard(); + resolve(false); + }, + }); + }); + } + + return true; + } +} + +export const currencyFormView = { + ...formView, + Controller: CurrencyFormController, +}; + +registry.category("views").add("currency_form", currencyFormView); diff --git a/frontend/account/static/src/components/currency_form/open_decimal_precision_btn.js b/frontend/account/static/src/components/currency_form/open_decimal_precision_btn.js new file mode 100644 index 0000000..636774b --- /dev/null +++ b/frontend/account/static/src/components/currency_form/open_decimal_precision_btn.js @@ -0,0 +1,24 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; +import { useService } from "@web/core/utils/hooks"; +import { Component } from "@odoo/owl"; + +class OpenDecimalPrecisionButton extends Component { + static template = "account.OpenDecimalPrecisionButton"; + static props = { ...standardFieldProps }; + + setup() { + this.action = useService("action"); + } + + async discardAndOpen() { + await this.props.record.discard(); + this.action.doAction("base.action_decimal_precision_form"); + } +} + +registry.category("fields").add("open_decimal_precision_button", { + component: OpenDecimalPrecisionButton, +}); diff --git a/frontend/account/static/src/components/currency_form/open_decimal_precision_btn_template.xml b/frontend/account/static/src/components/currency_form/open_decimal_precision_btn_template.xml new file mode 100644 index 0000000..6ccdb2e --- /dev/null +++ b/frontend/account/static/src/components/currency_form/open_decimal_precision_btn_template.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/frontend/account/static/src/components/document_file_uploader/document_file_uploader.js b/frontend/account/static/src/components/document_file_uploader/document_file_uploader.js new file mode 100644 index 0000000..ed45730 --- /dev/null +++ b/frontend/account/static/src/components/document_file_uploader/document_file_uploader.js @@ -0,0 +1,78 @@ +import { useService } from "@web/core/utils/hooks"; +import { FileUploader } from "@web/views/fields/file_handler"; +import { standardWidgetProps } from "@web/views/widgets/standard_widget_props"; + +import { Component, markup } from "@odoo/owl"; + +export class DocumentFileUploader extends Component { + static template = "account.DocumentFileUploader"; + static components = { + FileUploader, + }; + static props = { + ...standardWidgetProps, + record: { type: Object, optional: true }, + slots: { type: Object, optional: true }, + resModel: { type: String, optional: true }, + }; + + setup() { + this.orm = useService("orm"); + this.action = useService("action"); + this.notification = useService("notification"); + this.attachmentIdsToProcess = []; + this.extraContext = this.getExtraContext(); + } + + // To pass extra context while creating record + getExtraContext() { + return {}; + } + + async onFileUploaded(file) { + const att_data = { + name: file.name, + mimetype: file.type, + datas: file.data, + }; + // clean the context to ensure the `create` call doesn't fail from unknown `default_*` context + const cleanContext = Object.fromEntries(Object.entries(this.env.searchModel.context).filter(([key]) => !key.startsWith('default_'))); + const [att_id] = await this.orm.create("ir.attachment", [att_data], {context: cleanContext}); + this.attachmentIdsToProcess.push(att_id); + } + + // To define specific resModal from another model + getResModel() { + return this.props.resModel; + } + + async onUploadComplete() { + const resModal = this.getResModel(); + let action; + try { + action = await this.orm.call( + resModal, + "create_document_from_attachment", + ["", this.attachmentIdsToProcess], + { context: { ...this.extraContext, ...this.env.searchModel.context } } + ); + } finally { + // ensures attachments are cleared on success as well as on error + this.attachmentIdsToProcess = []; + } + if (action.context && action.context.notifications) { + for (const [file, msg] of Object.entries(action.context.notifications)) { + this.notification.add(msg, { + title: file, + type: "info", + sticky: true, + }); + } + delete action.context.notifications; + } + if (action.help?.length) { + action.help = markup(action.help); + } + this.action.doAction(action); + } +} diff --git a/frontend/account/static/src/components/document_file_uploader/document_file_uploader.xml b/frontend/account/static/src/components/document_file_uploader/document_file_uploader.xml new file mode 100644 index 0000000..a045a36 --- /dev/null +++ b/frontend/account/static/src/components/document_file_uploader/document_file_uploader.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/account/static/src/components/document_state/document_state_field.js b/frontend/account/static/src/components/document_state/document_state_field.js new file mode 100644 index 0000000..8d59249 --- /dev/null +++ b/frontend/account/static/src/components/document_state/document_state_field.js @@ -0,0 +1,69 @@ +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; + +import { SelectionField, selectionField } from "@web/views/fields/selection/selection_field"; + +import { Component } from "@odoo/owl"; + +export class DocumentStatePopover extends Component { + static template = "account.DocumentStatePopover"; + static props = { + close: Function, + onClose: Function, + copyText: Function, + message: String, + }; +} + +export class DocumentState extends SelectionField { + static template = "account.DocumentState"; + + setup() { + super.setup(); + this.popover = useService("popover"); + this.notification = useService("notification"); + } + + get message() { + return this.props.record.data.message; + } + + copyText() { + navigator.clipboard.writeText(this.message); + this.notification.add(_t("Text copied"), { type: "success" }); + this.popoverCloseFn(); + this.popoverCloseFn = null; + } + + showMessagePopover(ev) { + const close = () => { + this.popoverCloseFn(); + this.popoverCloseFn = null; + }; + + if (this.popoverCloseFn) { + close(); + return; + } + + this.popoverCloseFn = this.popover.add( + ev.currentTarget, + DocumentStatePopover, + { + message: this.message, + copyText: this.copyText.bind(this), + onClose: close, + }, + { + closeOnClickAway: true, + position: "top", + }, + ); + } +} + +registry.category("fields").add("account_document_state", { + ...selectionField, + component: DocumentState, +}); diff --git a/frontend/account/static/src/components/document_state/document_state_field.scss b/frontend/account/static/src/components/document_state/document_state_field.scss new file mode 100644 index 0000000..3d4322b --- /dev/null +++ b/frontend/account/static/src/components/document_state/document_state_field.scss @@ -0,0 +1,10 @@ +.account_document_state_popover { + width: 500px; +} + +.account_document_state_popover_clone { + &:hover { + color: $o-enterprise-action-color !important; + cursor: pointer; + } +} diff --git a/frontend/account/static/src/components/document_state/document_state_field.xml b/frontend/account/static/src/components/document_state/document_state_field.xml new file mode 100644 index 0000000..add0190 --- /dev/null +++ b/frontend/account/static/src/components/document_state/document_state_field.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/frontend/account/static/src/components/dynamic_selection/dynamic_selection.js b/frontend/account/static/src/components/dynamic_selection/dynamic_selection.js new file mode 100644 index 0000000..1a85d56 --- /dev/null +++ b/frontend/account/static/src/components/dynamic_selection/dynamic_selection.js @@ -0,0 +1,63 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { SelectionField, selectionField } from "@web/views/fields/selection/selection_field"; + +export class DynamicSelectionField extends SelectionField { + + static props = { + ...SelectionField.props, + available_field: { type: String }, + } + + get availableOptions() { + return this.props.record.data[this.props.available_field]?.split(",") || []; + } + + /** + * Filter the options with the accepted available options. + * @override + */ + get options() { + const availableOptions = this.availableOptions; + return super.options.filter(x => availableOptions.includes(x[0])); + } + + /** + * In dynamic selection field, sometimes we can have no options available. + * This override handles that case by adding optional chaining when accessing the found options. + * @override + */ + get string() { + if (this.type === "selection") { + return this.props.record.data[this.props.name] !== false + ? this.options.find((o) => o[0] === this.props.record.data[this.props.name])?.[1] + : ""; + } + return super.string; + } + +} + +/* +EXAMPLE USAGE: + +In python: +the_available_field = fields.Char() # string of comma separated available selection field keys +the_selection_field = fields.Selection([ ... ]) + +In the views: + + + */ + +registry.category("fields").add("dynamic_selection", { + ...selectionField, + component: DynamicSelectionField, + extractProps: (fieldInfo, dynamicInfo) => ({ + ...selectionField.extractProps(fieldInfo, dynamicInfo), + available_field: fieldInfo.options.available_field, + }), +}) diff --git a/frontend/account/static/src/components/fetch_einvoices/fetch_einvoices.xml b/frontend/account/static/src/components/fetch_einvoices/fetch_einvoices.xml new file mode 100644 index 0000000..1bec8ec --- /dev/null +++ b/frontend/account/static/src/components/fetch_einvoices/fetch_einvoices.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/account/static/src/components/fetch_einvoices/fetch_einvoices_cog.js b/frontend/account/static/src/components/fetch_einvoices/fetch_einvoices_cog.js new file mode 100644 index 0000000..bf46d66 --- /dev/null +++ b/frontend/account/static/src/components/fetch_einvoices/fetch_einvoices_cog.js @@ -0,0 +1,59 @@ +import { Component } from "@odoo/owl"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { _t } from "@web/core/l10n/translation"; +import { ACTIONS_GROUP_NUMBER } from "@web/search/action_menus/action_menus"; + +const cogMenuRegistry = registry.category("cogMenu"); + +export class FetchEInvoices extends Component { + static template = "account.FetchEInvoices"; + static props = {}; + static components = { DropdownItem }; + + setup() { + super.setup(); + this.action = useService("action"); + } + + get buttonAction() { + return this.env.searchModel.globalContext.show_fetch_in_einvoices_button + ? "button_fetch_in_einvoices" + : "button_refresh_out_einvoices_status"; + } + + get buttonLabel() { + return this.env.searchModel.globalContext.show_fetch_in_einvoices_button + ? _t("Fetch e-Invoices") + : _t("Refresh e-Invoices Status"); + } + + fetchEInvoices() { + const journalId = this.env.searchModel.globalContext.default_journal_id; + if (!journalId) { + return; + } + + this.action.doActionButton({ + type: "object", + resId: journalId, + name: this.buttonAction, + resModel: "account.journal", + onClose: () => window.location.reload(), + }); + } +} + +export const fetchEInvoicesActionMenu = { + Component: FetchEInvoices, + groupNumber: ACTIONS_GROUP_NUMBER, + isDisplayed: ({ config, searchModel }) => + searchModel.resModel === "account.move" && + (searchModel.globalContext.default_journal_id || false) && + (searchModel.globalContext.show_fetch_in_einvoices_button || + searchModel.globalContext.show_refresh_out_einvoices_status_button || + false), +}; + +cogMenuRegistry.add("account-fetch-e-invoices", fetchEInvoicesActionMenu, { sequence: 11 }); diff --git a/frontend/account/static/src/components/grouped_view_widget/grouped_view_widget.js b/frontend/account/static/src/components/grouped_view_widget/grouped_view_widget.js new file mode 100644 index 0000000..8fa51ba --- /dev/null +++ b/frontend/account/static/src/components/grouped_view_widget/grouped_view_widget.js @@ -0,0 +1,30 @@ +import { registry } from "@web/core/registry"; +import { Component } from "@odoo/owl"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; + +class ListItem extends Component { + static template = "account.GroupedItemTemplate"; + static props = ["item_vals", "options"]; +} + +class ListGroup extends Component { + static template = "account.GroupedItemsTemplate"; + static components = { ListItem }; + static props = ["group_vals", "options"]; +} + +class ShowGroupedList extends Component { + static template = "account.GroupedListTemplate"; + static components = { ListGroup }; + static props = {...standardFieldProps}; + getValue() { + const value = this.props.record.data[this.props.name]; + return value + ? JSON.parse(value) + : { groups_vals: [], options: { discarded_number: "", columns: [] } }; + } +} + +registry.category("fields").add("grouped_view_widget", { + component: ShowGroupedList, +}); diff --git a/frontend/account/static/src/components/grouped_view_widget/grouped_view_widget.xml b/frontend/account/static/src/components/grouped_view_widget/grouped_view_widget.xml new file mode 100644 index 0000000..b4faa93 --- /dev/null +++ b/frontend/account/static/src/components/grouped_view_widget/grouped_view_widget.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + +
+ +
+ + are not shown in the preview + +
+ + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/frontend/account/static/src/components/mail_attachments/mail_attachments.js b/frontend/account/static/src/components/mail_attachments/mail_attachments.js new file mode 100644 index 0000000..045a97b --- /dev/null +++ b/frontend/account/static/src/components/mail_attachments/mail_attachments.js @@ -0,0 +1,74 @@ +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { FileInput } from "@web/core/file_input/file_input"; +import { Component, onWillUnmount } from "@odoo/owl"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; + +export class MailAttachments extends Component { + static template = "account.mail_attachments"; + static components = { FileInput }; + static props = {...standardFieldProps}; + + setup() { + this.orm = useService("orm"); + this.notification = useService("notification"); + this.attachmentIdsToUnlink = new Set(); + + onWillUnmount(this.onWillUnmount); + } + + get attachments() { + return this.props.record.data[this.props.name] || []; + } + + get renderedAttachments() { + const attachments = JSON.parse(JSON.stringify(this.attachments)); + const attachmentsNotSupported = this.props.record.data.attachments_not_supported || {}; + for (const attachment of attachments) { + if (attachment.id && attachment.id in attachmentsNotSupported) { + attachment.tooltip = attachmentsNotSupported[attachment.id]; + } + } + return attachments; + } + + onFileRemove(deleteId) { + const newValue = []; + + for (let item of this.attachments) { + if (item.id === deleteId) { + if (item.placeholder || item.protect_from_deletion) { + const copyItem = Object.assign({ skip: true }, item); + newValue.push(copyItem); + } else { + this.attachmentIdsToUnlink.add(item.id); + } + } else { + newValue.push(item); + } + } + + this.props.record.update({ [this.props.name]: newValue }); + } + + async onWillUnmount() { + // Unlink added attachments if the wizard is not saved. + if (!this.props.record.resId) { + this.attachments.forEach((item) => { + if (item.manual) { + this.attachmentIdsToUnlink.add(item.id); + } + }); + } + + if (this.attachmentIdsToUnlink.size) { + await this.orm.unlink("ir.attachment", Array.from(this.attachmentIdsToUnlink)); + } + } +} + +export const mailAttachments = { + component: MailAttachments, +}; + +registry.category("fields").add("mail_attachments", mailAttachments); diff --git a/frontend/account/static/src/components/mail_attachments/mail_attachments.xml b/frontend/account/static/src/components/mail_attachments/mail_attachments.xml new file mode 100644 index 0000000..2e7a7c1 --- /dev/null +++ b/frontend/account/static/src/components/mail_attachments/mail_attachments.xml @@ -0,0 +1,20 @@ + + + +
    + + +
  • + + + + + +
  • +
    +
    +
+
+
diff --git a/frontend/account/static/src/components/mail_attachments/mail_attachments_selector.js b/frontend/account/static/src/components/mail_attachments/mail_attachments_selector.js new file mode 100644 index 0000000..de246ad --- /dev/null +++ b/frontend/account/static/src/components/mail_attachments/mail_attachments_selector.js @@ -0,0 +1,51 @@ +import { registry } from "@web/core/registry"; +import { useService } from "@web/core/utils/hooks"; +import { FileUploader } from "@web/views/fields/file_handler"; +import { Component } from "@odoo/owl"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; +import { useX2ManyCrud } from "@web/views/fields/relational_utils"; +import { dataUrlToBlob } from "@mail/core/common/attachment_uploader_hook"; + +export class MailAttachments extends Component { + static template = "mail.MailComposerAttachmentSelector"; + static components = { FileUploader }; + static props = {...standardFieldProps}; + + setup() { + this.mailStore = useService("mail.store"); + this.attachmentUploadService = useService("mail.attachment_upload"); + this.operations = useX2ManyCrud(() => { + return this.props.record.data["attachment_ids"]; + }, true); + } + + get attachments() { + return this.props.record.data[this.props.name] || []; + } + + async onFileUploaded({ name, data, type }) { + const resIds = JSON.parse(this.props.record.data.res_ids); + const thread = await this.mailStore.Thread.insert({ + model: this.props.record.data.model, + id: resIds[0], + }); + + const file = new File([dataUrlToBlob(data, type)], name, { type }); + const attachment = await this.attachmentUploadService.upload(thread, thread.composer, file); + + let fileDict = { + id: attachment.id, + name: attachment.name, + mimetype: attachment.mimetype, + placeholder: false, + manual: true, + }; + this.props.record.update({ [this.props.name]: this.attachments.concat([fileDict]) }); + } +} + +export const mailAttachments = { + component: MailAttachments, +}; + +registry.category("fields").add("mail_attachments_selector", mailAttachments); diff --git a/frontend/account/static/src/components/many2many_tags_banks/many2many_tags_banks.js b/frontend/account/static/src/components/many2many_tags_banks/many2many_tags_banks.js new file mode 100644 index 0000000..c9530d7 --- /dev/null +++ b/frontend/account/static/src/components/many2many_tags_banks/many2many_tags_banks.js @@ -0,0 +1,80 @@ +import { + many2ManyTagsFieldColorEditable, + Many2ManyTagsFieldColorEditable, +} from "@web/views/fields/many2many_tags/many2many_tags_field"; +import { useService } from "@web/core/utils/hooks"; +import { registry } from "@web/core/registry"; +import { TagsList } from "@web/core/tags_list/tags_list"; +import { _t } from "@web/core/l10n/translation"; +import { onMounted } from "@odoo/owl"; + +export class FieldMany2ManyTagsBanksTagsList extends TagsList { + static template = "FieldMany2ManyTagsBanksTagsList"; +} + +export class FieldMany2ManyTagsBanks extends Many2ManyTagsFieldColorEditable { + static template = "account.FieldMany2ManyTagsBanks"; + static components = { + ...FieldMany2ManyTagsBanks.components, + TagsList: FieldMany2ManyTagsBanksTagsList, + }; + + setup() { + super.setup(); + this.actionService = useService("action"); + onMounted(async () => { + // Needed when you create a partner (from a move for example), we want the partner to be saved to be able + // to have it as account holder + const isDirty = await this.props.record.model.root.isDirty(); + if (isDirty) { + this.props.record.model.root.save(); + } + }); + } + + getTagProps(record) { + return { + ...super.getTagProps(record), + allowOutPayment: record.data?.allow_out_payment, + }; + } + + openBanksListView() { + this.actionService.doAction({ + type: "ir.actions.act_window", + name: _t("Banks"), + res_model: this.relation, + views: [ + [false, "list"], + [false, "form"], + ], + domain: this.getDomain(), + target: "current", + }); + } +} + +export const fieldMany2ManyTagsBanks = { + ...many2ManyTagsFieldColorEditable, + component: FieldMany2ManyTagsBanks, + supportedOptions: [ + ...(many2ManyTagsFieldColorEditable.supportedOptions || []), + { + label: _t("Allows out payments"), + name: "allow_out_payment_field", + type: "boolean", + }, + ], + additionalClasses: [ + ...(many2ManyTagsFieldColorEditable.additionalClasses || []), + "o_field_many2many_tags", + ], + relatedFields: ({ options }) => { + return [ + ...many2ManyTagsFieldColorEditable.relatedFields({ options }), + { name: options.allow_out_payment_field, type: "boolean", readonly: false }, + ]; + }, +}; + +registry.category("fields").add("many2many_tags_banks", fieldMany2ManyTagsBanks); diff --git a/frontend/account/static/src/components/many2many_tags_banks/many2many_tags_banks.xml b/frontend/account/static/src/components/many2many_tags_banks/many2many_tags_banks.xml new file mode 100644 index 0000000..6c51d43 --- /dev/null +++ b/frontend/account/static/src/components/many2many_tags_banks/many2many_tags_banks.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + +
+ + + +
+ +
+ + + + + account.ProductCatalogSearchPanelContent + + + diff --git a/frontend/account/static/src/components/product_label_section_and_note_field/product_label_section_and_note_field.js b/frontend/account/static/src/components/product_label_section_and_note_field/product_label_section_and_note_field.js new file mode 100644 index 0000000..a4c56f6 --- /dev/null +++ b/frontend/account/static/src/components/product_label_section_and_note_field/product_label_section_and_note_field.js @@ -0,0 +1,86 @@ +import { _t } from "@web/core/l10n/translation"; +import { buildM2OFieldDescription, extractM2OFieldProps, m2oSupportedOptions } from "@web/views/fields/many2one/many2one_field"; +import { registry } from "@web/core/registry"; +import { ProductNameAndDescriptionField } from "@product/product_name_and_description/product_name_and_description"; + +export class ProductLabelSectionAndNoteField extends ProductNameAndDescriptionField { + static template = "account.ProductLabelSectionAndNoteField"; + static props = { + ...super.props, + show_label_warning: { type: Boolean, optional: true, default: false }, + }; + + static descriptionColumn = "name"; + + get sectionAndNoteClasses() { + return { + "fw-bolder": this.isSection, + "fw-bold": this.isSubSection, + "fst-italic": this.isNote(), + "text-warning": this.shouldShowWarning(), + }; + } + + get sectionAndNoteIsReadonly() { + return ( + this.props.readonly + && this.isProductClickable + && (["cancel", "posted"].includes(this.props.record.evalContext.parent.state) + || this.props.record.evalContext.parent.locked) + ) + } + + get isSection() { + return this.props.record.data.display_type === "line_section"; + } + + get isSubSection() { + return this.props.record.data.display_type === "line_subsection"; + } + + get isSectionOrSubSection() { + return this.isSection || this.isSubSection; + } + + isNote(record = null) { + record = record || this.props.record; + return record.data.display_type === "line_note"; + } + + parseLabel(value) { + return (this.productName && value && this.productName.concat("\n", value)) + || (this.productName && !value && this.productName) + || (value || ""); + } + + shouldShowWarning() { + return ( + !this.productName && + this.props.show_label_warning && + !this.isSectionOrSubSection && + !this.isNote() + ); + } +} + +export const productLabelSectionAndNoteField = { + ...buildM2OFieldDescription(ProductLabelSectionAndNoteField), + listViewWidth: [240, 400], + supportedOptions: [ + ...m2oSupportedOptions, + { + label: _t("Show Label Warning"), + name: "show_label_warning", + type: "boolean", + default: false + }, + ], + extractProps({ options }) { + const props = extractM2OFieldProps(...arguments); + props.show_label_warning = options.show_label_warning; + return props; + }, +}; +registry + .category("fields") + .add("product_label_section_and_note_field", productLabelSectionAndNoteField); diff --git a/frontend/account/static/src/components/product_label_section_and_note_field/product_label_section_and_note_field.scss b/frontend/account/static/src/components/product_label_section_and_note_field/product_label_section_and_note_field.scss new file mode 100644 index 0000000..f02e625 --- /dev/null +++ b/frontend/account/static/src/components/product_label_section_and_note_field/product_label_section_and_note_field.scss @@ -0,0 +1,14 @@ +.o_field_product_label_section_and_note_cell { + + textarea { + resize: none; + } + + div.o_input { + white-space: pre-wrap; + } + + @include media-only(print) { + height: auto !important; + } +} diff --git a/frontend/account/static/src/components/product_label_section_and_note_field/product_label_section_and_note_field.xml b/frontend/account/static/src/components/product_label_section_and_note_field/product_label_section_and_note_field.xml new file mode 100644 index 0000000..9d1101d --- /dev/null +++ b/frontend/account/static/src/components/product_label_section_and_note_field/product_label_section_and_note_field.xml @@ -0,0 +1,66 @@ + + + + +
+ + +
+
+
+
+
+ + +
+
+ + This is used for ornamental images, like borders or watermarks. + +
+
+
+
+ + +
+
+ + +
+
+ Edit alt text (image description) +
+
+
+
+
+ Couldn’t create alt text automatically + Please write your own alt text or try again later. +
+ +
+
+
+ + + +
+
+ + + +
+
+ Image alt text settings +
+
+ Automatic alt text +
+
+
+ + +
+
+ Suggests descriptions to help people who can’t see the image or when the image doesn’t load. Learn more +
+
+
+
+ Alt text AI model (180MB) +
+ Runs locally on your device so your data stays private. Required for automatic alt text. +
+
+ + +
+
+
+
+
+ Alt text editor +
+
+ + +
+
+ Helps you make sure all your images have alt text. +
+
+
+
+ +
+
+
+ +
+ Preparing document for printing… +
+
+ + 0% +
+
+ +
+
+
+ + +
+
+ + + + + + +
+
+ +
+ + +
+ + diff --git a/frontend/web/static/lib/popper/popper.js b/frontend/web/static/lib/popper/popper.js new file mode 100644 index 0000000..a00f139 --- /dev/null +++ b/frontend/web/static/lib/popper/popper.js @@ -0,0 +1,1825 @@ +/** + * @popperjs/core v2.11.8 - MIT License + */ + +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : + typeof define === 'function' && define.amd ? define(['exports'], factory) : + (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Popper = {})); +}(this, (function (exports) { 'use strict'; + + function getWindow(node) { + if (node == null) { + return window; + } + + if (node.toString() !== '[object Window]') { + var ownerDocument = node.ownerDocument; + return ownerDocument ? ownerDocument.defaultView || window : window; + } + + return node; + } + + function isElement(node) { + var OwnElement = getWindow(node).Element; + return node instanceof OwnElement || node instanceof Element; + } + + function isHTMLElement(node) { + var OwnElement = getWindow(node).HTMLElement; + return node instanceof OwnElement || node instanceof HTMLElement; + } + + function isShadowRoot(node) { + // IE 11 has no ShadowRoot + if (typeof ShadowRoot === 'undefined') { + return false; + } + + var OwnElement = getWindow(node).ShadowRoot; + return node instanceof OwnElement || node instanceof ShadowRoot; + } + + var max = Math.max; + var min = Math.min; + var round = Math.round; + + function getUAString() { + var uaData = navigator.userAgentData; + + if (uaData != null && uaData.brands && Array.isArray(uaData.brands)) { + return uaData.brands.map(function (item) { + return item.brand + "/" + item.version; + }).join(' '); + } + + return navigator.userAgent; + } + + function isLayoutViewport() { + return !/^((?!chrome|android).)*safari/i.test(getUAString()); + } + + function getBoundingClientRect(element, includeScale, isFixedStrategy) { + if (includeScale === void 0) { + includeScale = false; + } + + if (isFixedStrategy === void 0) { + isFixedStrategy = false; + } + + var clientRect = element.getBoundingClientRect(); + var scaleX = 1; + var scaleY = 1; + + if (includeScale && isHTMLElement(element)) { + scaleX = element.offsetWidth > 0 ? round(clientRect.width) / element.offsetWidth || 1 : 1; + scaleY = element.offsetHeight > 0 ? round(clientRect.height) / element.offsetHeight || 1 : 1; + } + + var _ref = isElement(element) ? getWindow(element) : window, + visualViewport = _ref.visualViewport; + + var addVisualOffsets = !isLayoutViewport() && isFixedStrategy; + var x = (clientRect.left + (addVisualOffsets && visualViewport ? visualViewport.offsetLeft : 0)) / scaleX; + var y = (clientRect.top + (addVisualOffsets && visualViewport ? visualViewport.offsetTop : 0)) / scaleY; + var width = clientRect.width / scaleX; + var height = clientRect.height / scaleY; + return { + width: width, + height: height, + top: y, + right: x + width, + bottom: y + height, + left: x, + x: x, + y: y + }; + } + + function getWindowScroll(node) { + var win = getWindow(node); + var scrollLeft = win.pageXOffset; + var scrollTop = win.pageYOffset; + return { + scrollLeft: scrollLeft, + scrollTop: scrollTop + }; + } + + function getHTMLElementScroll(element) { + return { + scrollLeft: element.scrollLeft, + scrollTop: element.scrollTop + }; + } + + function getNodeScroll(node) { + if (node === getWindow(node) || !isHTMLElement(node)) { + return getWindowScroll(node); + } else { + return getHTMLElementScroll(node); + } + } + + function getNodeName(element) { + return element ? (element.nodeName || '').toLowerCase() : null; + } + + function getDocumentElement(element) { + // $FlowFixMe[incompatible-return]: assume body is always available + return ((isElement(element) ? element.ownerDocument : // $FlowFixMe[prop-missing] + element.document) || window.document).documentElement; + } + + function getWindowScrollBarX(element) { + // If has a CSS width greater than the viewport, then this will be + // incorrect for RTL. + // Popper 1 is broken in this case and never had a bug report so let's assume + // it's not an issue. I don't think anyone ever specifies width on + // anyway. + // Browsers where the left scrollbar doesn't cause an issue report `0` for + // this (e.g. Edge 2019, IE11, Safari) + return getBoundingClientRect(getDocumentElement(element)).left + getWindowScroll(element).scrollLeft; + } + + function getComputedStyle(element) { + return getWindow(element).getComputedStyle(element); + } + + function isScrollParent(element) { + // Firefox wants us to check `-x` and `-y` variations as well + var _getComputedStyle = getComputedStyle(element), + overflow = _getComputedStyle.overflow, + overflowX = _getComputedStyle.overflowX, + overflowY = _getComputedStyle.overflowY; + + return /auto|scroll|overlay|hidden/.test(overflow + overflowY + overflowX); + } + + function isElementScaled(element) { + var rect = element.getBoundingClientRect(); + var scaleX = round(rect.width) / element.offsetWidth || 1; + var scaleY = round(rect.height) / element.offsetHeight || 1; + return scaleX !== 1 || scaleY !== 1; + } // Returns the composite rect of an element relative to its offsetParent. + // Composite means it takes into account transforms as well as layout. + + + function getCompositeRect(elementOrVirtualElement, offsetParent, isFixed) { + if (isFixed === void 0) { + isFixed = false; + } + + var isOffsetParentAnElement = isHTMLElement(offsetParent); + var offsetParentIsScaled = isHTMLElement(offsetParent) && isElementScaled(offsetParent); + var documentElement = getDocumentElement(offsetParent); + var rect = getBoundingClientRect(elementOrVirtualElement, offsetParentIsScaled, isFixed); + var scroll = { + scrollLeft: 0, + scrollTop: 0 + }; + var offsets = { + x: 0, + y: 0 + }; + + if (isOffsetParentAnElement || !isOffsetParentAnElement && !isFixed) { + if (getNodeName(offsetParent) !== 'body' || // https://github.com/popperjs/popper-core/issues/1078 + isScrollParent(documentElement)) { + scroll = getNodeScroll(offsetParent); + } + + if (isHTMLElement(offsetParent)) { + offsets = getBoundingClientRect(offsetParent, true); + offsets.x += offsetParent.clientLeft; + offsets.y += offsetParent.clientTop; + } else if (documentElement) { + offsets.x = getWindowScrollBarX(documentElement); + } + } + + return { + x: rect.left + scroll.scrollLeft - offsets.x, + y: rect.top + scroll.scrollTop - offsets.y, + width: rect.width, + height: rect.height + }; + } + + // means it doesn't take into account transforms. + + function getLayoutRect(element) { + var clientRect = getBoundingClientRect(element); // Use the clientRect sizes if it's not been transformed. + // Fixes https://github.com/popperjs/popper-core/issues/1223 + + var width = element.offsetWidth; + var height = element.offsetHeight; + + if (Math.abs(clientRect.width - width) <= 1) { + width = clientRect.width; + } + + if (Math.abs(clientRect.height - height) <= 1) { + height = clientRect.height; + } + + return { + x: element.offsetLeft, + y: element.offsetTop, + width: width, + height: height + }; + } + + function getParentNode(element) { + if (getNodeName(element) === 'html') { + return element; + } + + return (// this is a quicker (but less type safe) way to save quite some bytes from the bundle + // $FlowFixMe[incompatible-return] + // $FlowFixMe[prop-missing] + element.assignedSlot || // step into the shadow DOM of the parent of a slotted node + element.parentNode || ( // DOM Element detected + isShadowRoot(element) ? element.host : null) || // ShadowRoot detected + // $FlowFixMe[incompatible-call]: HTMLElement is a Node + getDocumentElement(element) // fallback + + ); + } + + function getScrollParent(node) { + if (['html', 'body', '#document'].indexOf(getNodeName(node)) >= 0) { + // $FlowFixMe[incompatible-return]: assume body is always available + return node.ownerDocument.body; + } + + if (isHTMLElement(node) && isScrollParent(node)) { + return node; + } + + return getScrollParent(getParentNode(node)); + } + + /* + given a DOM element, return the list of all scroll parents, up the list of ancesors + until we get to the top window object. This list is what we attach scroll listeners + to, because if any of these parent elements scroll, we'll need to re-calculate the + reference element's position. + */ + + function listScrollParents(element, list) { + var _element$ownerDocumen; + + if (list === void 0) { + list = []; + } + + var scrollParent = getScrollParent(element); + var isBody = scrollParent === ((_element$ownerDocumen = element.ownerDocument) == null ? void 0 : _element$ownerDocumen.body); + var win = getWindow(scrollParent); + var target = isBody ? [win].concat(win.visualViewport || [], isScrollParent(scrollParent) ? scrollParent : []) : scrollParent; + var updatedList = list.concat(target); + return isBody ? updatedList : // $FlowFixMe[incompatible-call]: isBody tells us target will be an HTMLElement here + updatedList.concat(listScrollParents(getParentNode(target))); + } + + function isTableElement(element) { + return ['table', 'td', 'th'].indexOf(getNodeName(element)) >= 0; + } + + function getTrueOffsetParent(element) { + if (!isHTMLElement(element) || // https://github.com/popperjs/popper-core/issues/837 + getComputedStyle(element).position === 'fixed') { + return null; + } + + return element.offsetParent; + } // `.offsetParent` reports `null` for fixed elements, while absolute elements + // return the containing block + + + function getContainingBlock(element) { + var isFirefox = /firefox/i.test(getUAString()); + var isIE = /Trident/i.test(getUAString()); + + if (isIE && isHTMLElement(element)) { + // In IE 9, 10 and 11 fixed elements containing block is always established by the viewport + var elementCss = getComputedStyle(element); + + if (elementCss.position === 'fixed') { + return null; + } + } + + var currentNode = getParentNode(element); + + if (isShadowRoot(currentNode)) { + currentNode = currentNode.host; + } + + while (isHTMLElement(currentNode) && ['html', 'body'].indexOf(getNodeName(currentNode)) < 0) { + var css = getComputedStyle(currentNode); // This is non-exhaustive but covers the most common CSS properties that + // create a containing block. + // https://developer.mozilla.org/en-US/docs/Web/CSS/Containing_block#identifying_the_containing_block + + if (css.transform !== 'none' || css.perspective !== 'none' || css.contain === 'paint' || ['transform', 'perspective'].indexOf(css.willChange) !== -1 || isFirefox && css.willChange === 'filter' || isFirefox && css.filter && css.filter !== 'none') { + return currentNode; + } else { + currentNode = currentNode.parentNode; + } + } + + return null; + } // Gets the closest ancestor positioned element. Handles some edge cases, + // such as table ancestors and cross browser bugs. + + + function getOffsetParent(element) { + var window = getWindow(element); + var offsetParent = getTrueOffsetParent(element); + + while (offsetParent && isTableElement(offsetParent) && getComputedStyle(offsetParent).position === 'static') { + offsetParent = getTrueOffsetParent(offsetParent); + } + + if (offsetParent && (getNodeName(offsetParent) === 'html' || getNodeName(offsetParent) === 'body' && getComputedStyle(offsetParent).position === 'static')) { + return window; + } + + return offsetParent || getContainingBlock(element) || window; + } + + var top = 'top'; + var bottom = 'bottom'; + var right = 'right'; + var left = 'left'; + var auto = 'auto'; + var basePlacements = [top, bottom, right, left]; + var start = 'start'; + var end = 'end'; + var clippingParents = 'clippingParents'; + var viewport = 'viewport'; + var popper = 'popper'; + var reference = 'reference'; + var variationPlacements = /*#__PURE__*/basePlacements.reduce(function (acc, placement) { + return acc.concat([placement + "-" + start, placement + "-" + end]); + }, []); + var placements = /*#__PURE__*/[].concat(basePlacements, [auto]).reduce(function (acc, placement) { + return acc.concat([placement, placement + "-" + start, placement + "-" + end]); + }, []); // modifiers that need to read the DOM + + var beforeRead = 'beforeRead'; + var read = 'read'; + var afterRead = 'afterRead'; // pure-logic modifiers + + var beforeMain = 'beforeMain'; + var main = 'main'; + var afterMain = 'afterMain'; // modifier with the purpose to write to the DOM (or write into a framework state) + + var beforeWrite = 'beforeWrite'; + var write = 'write'; + var afterWrite = 'afterWrite'; + var modifierPhases = [beforeRead, read, afterRead, beforeMain, main, afterMain, beforeWrite, write, afterWrite]; + + function order(modifiers) { + var map = new Map(); + var visited = new Set(); + var result = []; + modifiers.forEach(function (modifier) { + map.set(modifier.name, modifier); + }); // On visiting object, check for its dependencies and visit them recursively + + function sort(modifier) { + visited.add(modifier.name); + var requires = [].concat(modifier.requires || [], modifier.requiresIfExists || []); + requires.forEach(function (dep) { + if (!visited.has(dep)) { + var depModifier = map.get(dep); + + if (depModifier) { + sort(depModifier); + } + } + }); + result.push(modifier); + } + + modifiers.forEach(function (modifier) { + if (!visited.has(modifier.name)) { + // check for visited object + sort(modifier); + } + }); + return result; + } + + function orderModifiers(modifiers) { + // order based on dependencies + var orderedModifiers = order(modifiers); // order based on phase + + return modifierPhases.reduce(function (acc, phase) { + return acc.concat(orderedModifiers.filter(function (modifier) { + return modifier.phase === phase; + })); + }, []); + } + + function debounce(fn) { + var pending; + return function () { + if (!pending) { + pending = new Promise(function (resolve) { + Promise.resolve().then(function () { + pending = undefined; + resolve(fn()); + }); + }); + } + + return pending; + }; + } + + function mergeByName(modifiers) { + var merged = modifiers.reduce(function (merged, current) { + var existing = merged[current.name]; + merged[current.name] = existing ? Object.assign({}, existing, current, { + options: Object.assign({}, existing.options, current.options), + data: Object.assign({}, existing.data, current.data) + }) : current; + return merged; + }, {}); // IE11 does not support Object.values + + return Object.keys(merged).map(function (key) { + return merged[key]; + }); + } + + function getViewportRect(element, strategy) { + var win = getWindow(element); + var html = getDocumentElement(element); + var visualViewport = win.visualViewport; + var width = html.clientWidth; + var height = html.clientHeight; + var x = 0; + var y = 0; + + if (visualViewport) { + width = visualViewport.width; + height = visualViewport.height; + var layoutViewport = isLayoutViewport(); + + if (layoutViewport || !layoutViewport && strategy === 'fixed') { + x = visualViewport.offsetLeft; + y = visualViewport.offsetTop; + } + } + + return { + width: width, + height: height, + x: x + getWindowScrollBarX(element), + y: y + }; + } + + // of the `` and `` rect bounds if horizontally scrollable + + function getDocumentRect(element) { + var _element$ownerDocumen; + + var html = getDocumentElement(element); + var winScroll = getWindowScroll(element); + var body = (_element$ownerDocumen = element.ownerDocument) == null ? void 0 : _element$ownerDocumen.body; + var width = max(html.scrollWidth, html.clientWidth, body ? body.scrollWidth : 0, body ? body.clientWidth : 0); + var height = max(html.scrollHeight, html.clientHeight, body ? body.scrollHeight : 0, body ? body.clientHeight : 0); + var x = -winScroll.scrollLeft + getWindowScrollBarX(element); + var y = -winScroll.scrollTop; + + if (getComputedStyle(body || html).direction === 'rtl') { + x += max(html.clientWidth, body ? body.clientWidth : 0) - width; + } + + return { + width: width, + height: height, + x: x, + y: y + }; + } + + function contains(parent, child) { + var rootNode = child.getRootNode && child.getRootNode(); // First, attempt with faster native method + + if (parent.contains(child)) { + return true; + } // then fallback to custom implementation with Shadow DOM support + else if (rootNode && isShadowRoot(rootNode)) { + var next = child; + + do { + if (next && parent.isSameNode(next)) { + return true; + } // $FlowFixMe[prop-missing]: need a better way to handle this... + + + next = next.parentNode || next.host; + } while (next); + } // Give up, the result is false + + + return false; + } + + function rectToClientRect(rect) { + return Object.assign({}, rect, { + left: rect.x, + top: rect.y, + right: rect.x + rect.width, + bottom: rect.y + rect.height + }); + } + + function getInnerBoundingClientRect(element, strategy) { + var rect = getBoundingClientRect(element, false, strategy === 'fixed'); + rect.top = rect.top + element.clientTop; + rect.left = rect.left + element.clientLeft; + rect.bottom = rect.top + element.clientHeight; + rect.right = rect.left + element.clientWidth; + rect.width = element.clientWidth; + rect.height = element.clientHeight; + rect.x = rect.left; + rect.y = rect.top; + return rect; + } + + function getClientRectFromMixedType(element, clippingParent, strategy) { + return clippingParent === viewport ? rectToClientRect(getViewportRect(element, strategy)) : isElement(clippingParent) ? getInnerBoundingClientRect(clippingParent, strategy) : rectToClientRect(getDocumentRect(getDocumentElement(element))); + } // A "clipping parent" is an overflowable container with the characteristic of + // clipping (or hiding) overflowing elements with a position different from + // `initial` + + + function getClippingParents(element) { + var clippingParents = listScrollParents(getParentNode(element)); + var canEscapeClipping = ['absolute', 'fixed'].indexOf(getComputedStyle(element).position) >= 0; + var clipperElement = canEscapeClipping && isHTMLElement(element) ? getOffsetParent(element) : element; + + if (!isElement(clipperElement)) { + return []; + } // $FlowFixMe[incompatible-return]: https://github.com/facebook/flow/issues/1414 + + + return clippingParents.filter(function (clippingParent) { + return isElement(clippingParent) && contains(clippingParent, clipperElement) && getNodeName(clippingParent) !== 'body'; + }); + } // Gets the maximum area that the element is visible in due to any number of + // clipping parents + + + function getClippingRect(element, boundary, rootBoundary, strategy) { + var mainClippingParents = boundary === 'clippingParents' ? getClippingParents(element) : [].concat(boundary); + var clippingParents = [].concat(mainClippingParents, [rootBoundary]); + var firstClippingParent = clippingParents[0]; + var clippingRect = clippingParents.reduce(function (accRect, clippingParent) { + var rect = getClientRectFromMixedType(element, clippingParent, strategy); + accRect.top = max(rect.top, accRect.top); + accRect.right = min(rect.right, accRect.right); + accRect.bottom = min(rect.bottom, accRect.bottom); + accRect.left = max(rect.left, accRect.left); + return accRect; + }, getClientRectFromMixedType(element, firstClippingParent, strategy)); + clippingRect.width = clippingRect.right - clippingRect.left; + clippingRect.height = clippingRect.bottom - clippingRect.top; + clippingRect.x = clippingRect.left; + clippingRect.y = clippingRect.top; + return clippingRect; + } + + function getBasePlacement(placement) { + return placement.split('-')[0]; + } + + function getVariation(placement) { + return placement.split('-')[1]; + } + + function getMainAxisFromPlacement(placement) { + return ['top', 'bottom'].indexOf(placement) >= 0 ? 'x' : 'y'; + } + + function computeOffsets(_ref) { + var reference = _ref.reference, + element = _ref.element, + placement = _ref.placement; + var basePlacement = placement ? getBasePlacement(placement) : null; + var variation = placement ? getVariation(placement) : null; + var commonX = reference.x + reference.width / 2 - element.width / 2; + var commonY = reference.y + reference.height / 2 - element.height / 2; + var offsets; + + switch (basePlacement) { + case top: + offsets = { + x: commonX, + y: reference.y - element.height + }; + break; + + case bottom: + offsets = { + x: commonX, + y: reference.y + reference.height + }; + break; + + case right: + offsets = { + x: reference.x + reference.width, + y: commonY + }; + break; + + case left: + offsets = { + x: reference.x - element.width, + y: commonY + }; + break; + + default: + offsets = { + x: reference.x, + y: reference.y + }; + } + + var mainAxis = basePlacement ? getMainAxisFromPlacement(basePlacement) : null; + + if (mainAxis != null) { + var len = mainAxis === 'y' ? 'height' : 'width'; + + switch (variation) { + case start: + offsets[mainAxis] = offsets[mainAxis] - (reference[len] / 2 - element[len] / 2); + break; + + case end: + offsets[mainAxis] = offsets[mainAxis] + (reference[len] / 2 - element[len] / 2); + break; + } + } + + return offsets; + } + + function getFreshSideObject() { + return { + top: 0, + right: 0, + bottom: 0, + left: 0 + }; + } + + function mergePaddingObject(paddingObject) { + return Object.assign({}, getFreshSideObject(), paddingObject); + } + + function expandToHashMap(value, keys) { + return keys.reduce(function (hashMap, key) { + hashMap[key] = value; + return hashMap; + }, {}); + } + + function detectOverflow(state, options) { + if (options === void 0) { + options = {}; + } + + var _options = options, + _options$placement = _options.placement, + placement = _options$placement === void 0 ? state.placement : _options$placement, + _options$strategy = _options.strategy, + strategy = _options$strategy === void 0 ? state.strategy : _options$strategy, + _options$boundary = _options.boundary, + boundary = _options$boundary === void 0 ? clippingParents : _options$boundary, + _options$rootBoundary = _options.rootBoundary, + rootBoundary = _options$rootBoundary === void 0 ? viewport : _options$rootBoundary, + _options$elementConte = _options.elementContext, + elementContext = _options$elementConte === void 0 ? popper : _options$elementConte, + _options$altBoundary = _options.altBoundary, + altBoundary = _options$altBoundary === void 0 ? false : _options$altBoundary, + _options$padding = _options.padding, + padding = _options$padding === void 0 ? 0 : _options$padding; + var paddingObject = mergePaddingObject(typeof padding !== 'number' ? padding : expandToHashMap(padding, basePlacements)); + var altContext = elementContext === popper ? reference : popper; + var popperRect = state.rects.popper; + var element = state.elements[altBoundary ? altContext : elementContext]; + var clippingClientRect = getClippingRect(isElement(element) ? element : element.contextElement || getDocumentElement(state.elements.popper), boundary, rootBoundary, strategy); + var referenceClientRect = getBoundingClientRect(state.elements.reference); + var popperOffsets = computeOffsets({ + reference: referenceClientRect, + element: popperRect, + strategy: 'absolute', + placement: placement + }); + var popperClientRect = rectToClientRect(Object.assign({}, popperRect, popperOffsets)); + var elementClientRect = elementContext === popper ? popperClientRect : referenceClientRect; // positive = overflowing the clipping rect + // 0 or negative = within the clipping rect + + var overflowOffsets = { + top: clippingClientRect.top - elementClientRect.top + paddingObject.top, + bottom: elementClientRect.bottom - clippingClientRect.bottom + paddingObject.bottom, + left: clippingClientRect.left - elementClientRect.left + paddingObject.left, + right: elementClientRect.right - clippingClientRect.right + paddingObject.right + }; + var offsetData = state.modifiersData.offset; // Offsets can be applied only to the popper element + + if (elementContext === popper && offsetData) { + var offset = offsetData[placement]; + Object.keys(overflowOffsets).forEach(function (key) { + var multiply = [right, bottom].indexOf(key) >= 0 ? 1 : -1; + var axis = [top, bottom].indexOf(key) >= 0 ? 'y' : 'x'; + overflowOffsets[key] += offset[axis] * multiply; + }); + } + + return overflowOffsets; + } + + var DEFAULT_OPTIONS = { + placement: 'bottom', + modifiers: [], + strategy: 'absolute' + }; + + function areValidElements() { + for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { + args[_key] = arguments[_key]; + } + + return !args.some(function (element) { + return !(element && typeof element.getBoundingClientRect === 'function'); + }); + } + + function popperGenerator(generatorOptions) { + if (generatorOptions === void 0) { + generatorOptions = {}; + } + + var _generatorOptions = generatorOptions, + _generatorOptions$def = _generatorOptions.defaultModifiers, + defaultModifiers = _generatorOptions$def === void 0 ? [] : _generatorOptions$def, + _generatorOptions$def2 = _generatorOptions.defaultOptions, + defaultOptions = _generatorOptions$def2 === void 0 ? DEFAULT_OPTIONS : _generatorOptions$def2; + return function createPopper(reference, popper, options) { + if (options === void 0) { + options = defaultOptions; + } + + var state = { + placement: 'bottom', + orderedModifiers: [], + options: Object.assign({}, DEFAULT_OPTIONS, defaultOptions), + modifiersData: {}, + elements: { + reference: reference, + popper: popper + }, + attributes: {}, + styles: {} + }; + var effectCleanupFns = []; + var isDestroyed = false; + var instance = { + state: state, + setOptions: function setOptions(setOptionsAction) { + var options = typeof setOptionsAction === 'function' ? setOptionsAction(state.options) : setOptionsAction; + cleanupModifierEffects(); + state.options = Object.assign({}, defaultOptions, state.options, options); + state.scrollParents = { + reference: isElement(reference) ? listScrollParents(reference) : reference.contextElement ? listScrollParents(reference.contextElement) : [], + popper: listScrollParents(popper) + }; // Orders the modifiers based on their dependencies and `phase` + // properties + + var orderedModifiers = orderModifiers(mergeByName([].concat(defaultModifiers, state.options.modifiers))); // Strip out disabled modifiers + + state.orderedModifiers = orderedModifiers.filter(function (m) { + return m.enabled; + }); + runModifierEffects(); + return instance.update(); + }, + // Sync update – it will always be executed, even if not necessary. This + // is useful for low frequency updates where sync behavior simplifies the + // logic. + // For high frequency updates (e.g. `resize` and `scroll` events), always + // prefer the async Popper#update method + forceUpdate: function forceUpdate() { + if (isDestroyed) { + return; + } + + var _state$elements = state.elements, + reference = _state$elements.reference, + popper = _state$elements.popper; // Don't proceed if `reference` or `popper` are not valid elements + // anymore + + if (!areValidElements(reference, popper)) { + return; + } // Store the reference and popper rects to be read by modifiers + + + state.rects = { + reference: getCompositeRect(reference, getOffsetParent(popper), state.options.strategy === 'fixed'), + popper: getLayoutRect(popper) + }; // Modifiers have the ability to reset the current update cycle. The + // most common use case for this is the `flip` modifier changing the + // placement, which then needs to re-run all the modifiers, because the + // logic was previously ran for the previous placement and is therefore + // stale/incorrect + + state.reset = false; + state.placement = state.options.placement; // On each update cycle, the `modifiersData` property for each modifier + // is filled with the initial data specified by the modifier. This means + // it doesn't persist and is fresh on each update. + // To ensure persistent data, use `${name}#persistent` + + state.orderedModifiers.forEach(function (modifier) { + return state.modifiersData[modifier.name] = Object.assign({}, modifier.data); + }); + + for (var index = 0; index < state.orderedModifiers.length; index++) { + if (state.reset === true) { + state.reset = false; + index = -1; + continue; + } + + var _state$orderedModifie = state.orderedModifiers[index], + fn = _state$orderedModifie.fn, + _state$orderedModifie2 = _state$orderedModifie.options, + _options = _state$orderedModifie2 === void 0 ? {} : _state$orderedModifie2, + name = _state$orderedModifie.name; + + if (typeof fn === 'function') { + state = fn({ + state: state, + options: _options, + name: name, + instance: instance + }) || state; + } + } + }, + // Async and optimistically optimized update – it will not be executed if + // not necessary (debounced to run at most once-per-tick) + update: debounce(function () { + return new Promise(function (resolve) { + instance.forceUpdate(); + resolve(state); + }); + }), + destroy: function destroy() { + cleanupModifierEffects(); + isDestroyed = true; + } + }; + + if (!areValidElements(reference, popper)) { + return instance; + } + + instance.setOptions(options).then(function (state) { + if (!isDestroyed && options.onFirstUpdate) { + options.onFirstUpdate(state); + } + }); // Modifiers have the ability to execute arbitrary code before the first + // update cycle runs. They will be executed in the same order as the update + // cycle. This is useful when a modifier adds some persistent data that + // other modifiers need to use, but the modifier is run after the dependent + // one. + + function runModifierEffects() { + state.orderedModifiers.forEach(function (_ref) { + var name = _ref.name, + _ref$options = _ref.options, + options = _ref$options === void 0 ? {} : _ref$options, + effect = _ref.effect; + + if (typeof effect === 'function') { + var cleanupFn = effect({ + state: state, + name: name, + instance: instance, + options: options + }); + + var noopFn = function noopFn() {}; + + effectCleanupFns.push(cleanupFn || noopFn); + } + }); + } + + function cleanupModifierEffects() { + effectCleanupFns.forEach(function (fn) { + return fn(); + }); + effectCleanupFns = []; + } + + return instance; + }; + } + + var passive = { + passive: true + }; + + function effect$2(_ref) { + var state = _ref.state, + instance = _ref.instance, + options = _ref.options; + var _options$scroll = options.scroll, + scroll = _options$scroll === void 0 ? true : _options$scroll, + _options$resize = options.resize, + resize = _options$resize === void 0 ? true : _options$resize; + var window = getWindow(state.elements.popper); + var scrollParents = [].concat(state.scrollParents.reference, state.scrollParents.popper); + + if (scroll) { + scrollParents.forEach(function (scrollParent) { + scrollParent.addEventListener('scroll', instance.update, passive); + }); + } + + if (resize) { + window.addEventListener('resize', instance.update, passive); + } + + return function () { + if (scroll) { + scrollParents.forEach(function (scrollParent) { + scrollParent.removeEventListener('scroll', instance.update, passive); + }); + } + + if (resize) { + window.removeEventListener('resize', instance.update, passive); + } + }; + } // eslint-disable-next-line import/no-unused-modules + + + var eventListeners = { + name: 'eventListeners', + enabled: true, + phase: 'write', + fn: function fn() {}, + effect: effect$2, + data: {} + }; + + function popperOffsets(_ref) { + var state = _ref.state, + name = _ref.name; + // Offsets are the actual position the popper needs to have to be + // properly positioned near its reference element + // This is the most basic placement, and will be adjusted by + // the modifiers in the next step + state.modifiersData[name] = computeOffsets({ + reference: state.rects.reference, + element: state.rects.popper, + strategy: 'absolute', + placement: state.placement + }); + } // eslint-disable-next-line import/no-unused-modules + + + var popperOffsets$1 = { + name: 'popperOffsets', + enabled: true, + phase: 'read', + fn: popperOffsets, + data: {} + }; + + var unsetSides = { + top: 'auto', + right: 'auto', + bottom: 'auto', + left: 'auto' + }; // Round the offsets to the nearest suitable subpixel based on the DPR. + // Zooming can change the DPR, but it seems to report a value that will + // cleanly divide the values into the appropriate subpixels. + + function roundOffsetsByDPR(_ref, win) { + var x = _ref.x, + y = _ref.y; + var dpr = win.devicePixelRatio || 1; + return { + x: round(x * dpr) / dpr || 0, + y: round(y * dpr) / dpr || 0 + }; + } + + function mapToStyles(_ref2) { + var _Object$assign2; + + var popper = _ref2.popper, + popperRect = _ref2.popperRect, + placement = _ref2.placement, + variation = _ref2.variation, + offsets = _ref2.offsets, + position = _ref2.position, + gpuAcceleration = _ref2.gpuAcceleration, + adaptive = _ref2.adaptive, + roundOffsets = _ref2.roundOffsets, + isFixed = _ref2.isFixed; + var _offsets$x = offsets.x, + x = _offsets$x === void 0 ? 0 : _offsets$x, + _offsets$y = offsets.y, + y = _offsets$y === void 0 ? 0 : _offsets$y; + + var _ref3 = typeof roundOffsets === 'function' ? roundOffsets({ + x: x, + y: y + }) : { + x: x, + y: y + }; + + x = _ref3.x; + y = _ref3.y; + var hasX = offsets.hasOwnProperty('x'); + var hasY = offsets.hasOwnProperty('y'); + var sideX = left; + var sideY = top; + var win = window; + + if (adaptive) { + var offsetParent = getOffsetParent(popper); + var heightProp = 'clientHeight'; + var widthProp = 'clientWidth'; + + if (offsetParent === getWindow(popper)) { + offsetParent = getDocumentElement(popper); + + if (getComputedStyle(offsetParent).position !== 'static' && position === 'absolute') { + heightProp = 'scrollHeight'; + widthProp = 'scrollWidth'; + } + } // $FlowFixMe[incompatible-cast]: force type refinement, we compare offsetParent with window above, but Flow doesn't detect it + + + offsetParent = offsetParent; + + if (placement === top || (placement === left || placement === right) && variation === end) { + sideY = bottom; + var offsetY = isFixed && offsetParent === win && win.visualViewport ? win.visualViewport.height : // $FlowFixMe[prop-missing] + offsetParent[heightProp]; + y -= offsetY - popperRect.height; + y *= gpuAcceleration ? 1 : -1; + } + + if (placement === left || (placement === top || placement === bottom) && variation === end) { + sideX = right; + var offsetX = isFixed && offsetParent === win && win.visualViewport ? win.visualViewport.width : // $FlowFixMe[prop-missing] + offsetParent[widthProp]; + x -= offsetX - popperRect.width; + x *= gpuAcceleration ? 1 : -1; + } + } + + var commonStyles = Object.assign({ + position: position + }, adaptive && unsetSides); + + var _ref4 = roundOffsets === true ? roundOffsetsByDPR({ + x: x, + y: y + }, getWindow(popper)) : { + x: x, + y: y + }; + + x = _ref4.x; + y = _ref4.y; + + if (gpuAcceleration) { + var _Object$assign; + + return Object.assign({}, commonStyles, (_Object$assign = {}, _Object$assign[sideY] = hasY ? '0' : '', _Object$assign[sideX] = hasX ? '0' : '', _Object$assign.transform = (win.devicePixelRatio || 1) <= 1 ? "translate(" + x + "px, " + y + "px)" : "translate3d(" + x + "px, " + y + "px, 0)", _Object$assign)); + } + + return Object.assign({}, commonStyles, (_Object$assign2 = {}, _Object$assign2[sideY] = hasY ? y + "px" : '', _Object$assign2[sideX] = hasX ? x + "px" : '', _Object$assign2.transform = '', _Object$assign2)); + } + + function computeStyles(_ref5) { + var state = _ref5.state, + options = _ref5.options; + var _options$gpuAccelerat = options.gpuAcceleration, + gpuAcceleration = _options$gpuAccelerat === void 0 ? true : _options$gpuAccelerat, + _options$adaptive = options.adaptive, + adaptive = _options$adaptive === void 0 ? true : _options$adaptive, + _options$roundOffsets = options.roundOffsets, + roundOffsets = _options$roundOffsets === void 0 ? true : _options$roundOffsets; + var commonStyles = { + placement: getBasePlacement(state.placement), + variation: getVariation(state.placement), + popper: state.elements.popper, + popperRect: state.rects.popper, + gpuAcceleration: gpuAcceleration, + isFixed: state.options.strategy === 'fixed' + }; + + if (state.modifiersData.popperOffsets != null) { + state.styles.popper = Object.assign({}, state.styles.popper, mapToStyles(Object.assign({}, commonStyles, { + offsets: state.modifiersData.popperOffsets, + position: state.options.strategy, + adaptive: adaptive, + roundOffsets: roundOffsets + }))); + } + + if (state.modifiersData.arrow != null) { + state.styles.arrow = Object.assign({}, state.styles.arrow, mapToStyles(Object.assign({}, commonStyles, { + offsets: state.modifiersData.arrow, + position: 'absolute', + adaptive: false, + roundOffsets: roundOffsets + }))); + } + + state.attributes.popper = Object.assign({}, state.attributes.popper, { + 'data-popper-placement': state.placement + }); + } // eslint-disable-next-line import/no-unused-modules + + + var computeStyles$1 = { + name: 'computeStyles', + enabled: true, + phase: 'beforeWrite', + fn: computeStyles, + data: {} + }; + + // and applies them to the HTMLElements such as popper and arrow + + function applyStyles(_ref) { + var state = _ref.state; + Object.keys(state.elements).forEach(function (name) { + var style = state.styles[name] || {}; + var attributes = state.attributes[name] || {}; + var element = state.elements[name]; // arrow is optional + virtual elements + + if (!isHTMLElement(element) || !getNodeName(element)) { + return; + } // Flow doesn't support to extend this property, but it's the most + // effective way to apply styles to an HTMLElement + // $FlowFixMe[cannot-write] + + + Object.assign(element.style, style); + Object.keys(attributes).forEach(function (name) { + var value = attributes[name]; + + if (value === false) { + element.removeAttribute(name); + } else { + element.setAttribute(name, value === true ? '' : value); + } + }); + }); + } + + function effect$1(_ref2) { + var state = _ref2.state; + var initialStyles = { + popper: { + position: state.options.strategy, + left: '0', + top: '0', + margin: '0' + }, + arrow: { + position: 'absolute' + }, + reference: {} + }; + Object.assign(state.elements.popper.style, initialStyles.popper); + state.styles = initialStyles; + + if (state.elements.arrow) { + Object.assign(state.elements.arrow.style, initialStyles.arrow); + } + + return function () { + Object.keys(state.elements).forEach(function (name) { + var element = state.elements[name]; + var attributes = state.attributes[name] || {}; + var styleProperties = Object.keys(state.styles.hasOwnProperty(name) ? state.styles[name] : initialStyles[name]); // Set all values to an empty string to unset them + + var style = styleProperties.reduce(function (style, property) { + style[property] = ''; + return style; + }, {}); // arrow is optional + virtual elements + + if (!isHTMLElement(element) || !getNodeName(element)) { + return; + } + + Object.assign(element.style, style); + Object.keys(attributes).forEach(function (attribute) { + element.removeAttribute(attribute); + }); + }); + }; + } // eslint-disable-next-line import/no-unused-modules + + + var applyStyles$1 = { + name: 'applyStyles', + enabled: true, + phase: 'write', + fn: applyStyles, + effect: effect$1, + requires: ['computeStyles'] + }; + + function distanceAndSkiddingToXY(placement, rects, offset) { + var basePlacement = getBasePlacement(placement); + var invertDistance = [left, top].indexOf(basePlacement) >= 0 ? -1 : 1; + + var _ref = typeof offset === 'function' ? offset(Object.assign({}, rects, { + placement: placement + })) : offset, + skidding = _ref[0], + distance = _ref[1]; + + skidding = skidding || 0; + distance = (distance || 0) * invertDistance; + return [left, right].indexOf(basePlacement) >= 0 ? { + x: distance, + y: skidding + } : { + x: skidding, + y: distance + }; + } + + function offset(_ref2) { + var state = _ref2.state, + options = _ref2.options, + name = _ref2.name; + var _options$offset = options.offset, + offset = _options$offset === void 0 ? [0, 0] : _options$offset; + var data = placements.reduce(function (acc, placement) { + acc[placement] = distanceAndSkiddingToXY(placement, state.rects, offset); + return acc; + }, {}); + var _data$state$placement = data[state.placement], + x = _data$state$placement.x, + y = _data$state$placement.y; + + if (state.modifiersData.popperOffsets != null) { + state.modifiersData.popperOffsets.x += x; + state.modifiersData.popperOffsets.y += y; + } + + state.modifiersData[name] = data; + } // eslint-disable-next-line import/no-unused-modules + + + var offset$1 = { + name: 'offset', + enabled: true, + phase: 'main', + requires: ['popperOffsets'], + fn: offset + }; + + var hash$1 = { + left: 'right', + right: 'left', + bottom: 'top', + top: 'bottom' + }; + function getOppositePlacement(placement) { + return placement.replace(/left|right|bottom|top/g, function (matched) { + return hash$1[matched]; + }); + } + + var hash = { + start: 'end', + end: 'start' + }; + function getOppositeVariationPlacement(placement) { + return placement.replace(/start|end/g, function (matched) { + return hash[matched]; + }); + } + + function computeAutoPlacement(state, options) { + if (options === void 0) { + options = {}; + } + + var _options = options, + placement = _options.placement, + boundary = _options.boundary, + rootBoundary = _options.rootBoundary, + padding = _options.padding, + flipVariations = _options.flipVariations, + _options$allowedAutoP = _options.allowedAutoPlacements, + allowedAutoPlacements = _options$allowedAutoP === void 0 ? placements : _options$allowedAutoP; + var variation = getVariation(placement); + var placements$1 = variation ? flipVariations ? variationPlacements : variationPlacements.filter(function (placement) { + return getVariation(placement) === variation; + }) : basePlacements; + var allowedPlacements = placements$1.filter(function (placement) { + return allowedAutoPlacements.indexOf(placement) >= 0; + }); + + if (allowedPlacements.length === 0) { + allowedPlacements = placements$1; + } // $FlowFixMe[incompatible-type]: Flow seems to have problems with two array unions... + + + var overflows = allowedPlacements.reduce(function (acc, placement) { + acc[placement] = detectOverflow(state, { + placement: placement, + boundary: boundary, + rootBoundary: rootBoundary, + padding: padding + })[getBasePlacement(placement)]; + return acc; + }, {}); + return Object.keys(overflows).sort(function (a, b) { + return overflows[a] - overflows[b]; + }); + } + + function getExpandedFallbackPlacements(placement) { + if (getBasePlacement(placement) === auto) { + return []; + } + + var oppositePlacement = getOppositePlacement(placement); + return [getOppositeVariationPlacement(placement), oppositePlacement, getOppositeVariationPlacement(oppositePlacement)]; + } + + function flip(_ref) { + var state = _ref.state, + options = _ref.options, + name = _ref.name; + + if (state.modifiersData[name]._skip) { + return; + } + + var _options$mainAxis = options.mainAxis, + checkMainAxis = _options$mainAxis === void 0 ? true : _options$mainAxis, + _options$altAxis = options.altAxis, + checkAltAxis = _options$altAxis === void 0 ? true : _options$altAxis, + specifiedFallbackPlacements = options.fallbackPlacements, + padding = options.padding, + boundary = options.boundary, + rootBoundary = options.rootBoundary, + altBoundary = options.altBoundary, + _options$flipVariatio = options.flipVariations, + flipVariations = _options$flipVariatio === void 0 ? true : _options$flipVariatio, + allowedAutoPlacements = options.allowedAutoPlacements; + var preferredPlacement = state.options.placement; + var basePlacement = getBasePlacement(preferredPlacement); + var isBasePlacement = basePlacement === preferredPlacement; + var fallbackPlacements = specifiedFallbackPlacements || (isBasePlacement || !flipVariations ? [getOppositePlacement(preferredPlacement)] : getExpandedFallbackPlacements(preferredPlacement)); + var placements = [preferredPlacement].concat(fallbackPlacements).reduce(function (acc, placement) { + return acc.concat(getBasePlacement(placement) === auto ? computeAutoPlacement(state, { + placement: placement, + boundary: boundary, + rootBoundary: rootBoundary, + padding: padding, + flipVariations: flipVariations, + allowedAutoPlacements: allowedAutoPlacements + }) : placement); + }, []); + var referenceRect = state.rects.reference; + var popperRect = state.rects.popper; + var checksMap = new Map(); + var makeFallbackChecks = true; + var firstFittingPlacement = placements[0]; + + for (var i = 0; i < placements.length; i++) { + var placement = placements[i]; + + var _basePlacement = getBasePlacement(placement); + + var isStartVariation = getVariation(placement) === start; + var isVertical = [top, bottom].indexOf(_basePlacement) >= 0; + var len = isVertical ? 'width' : 'height'; + var overflow = detectOverflow(state, { + placement: placement, + boundary: boundary, + rootBoundary: rootBoundary, + altBoundary: altBoundary, + padding: padding + }); + var mainVariationSide = isVertical ? isStartVariation ? right : left : isStartVariation ? bottom : top; + + if (referenceRect[len] > popperRect[len]) { + mainVariationSide = getOppositePlacement(mainVariationSide); + } + + var altVariationSide = getOppositePlacement(mainVariationSide); + var checks = []; + + if (checkMainAxis) { + checks.push(overflow[_basePlacement] <= 0); + } + + if (checkAltAxis) { + checks.push(overflow[mainVariationSide] <= 0, overflow[altVariationSide] <= 0); + } + + if (checks.every(function (check) { + return check; + })) { + firstFittingPlacement = placement; + makeFallbackChecks = false; + break; + } + + checksMap.set(placement, checks); + } + + if (makeFallbackChecks) { + // `2` may be desired in some cases – research later + var numberOfChecks = flipVariations ? 3 : 1; + + var _loop = function _loop(_i) { + var fittingPlacement = placements.find(function (placement) { + var checks = checksMap.get(placement); + + if (checks) { + return checks.slice(0, _i).every(function (check) { + return check; + }); + } + }); + + if (fittingPlacement) { + firstFittingPlacement = fittingPlacement; + return "break"; + } + }; + + for (var _i = numberOfChecks; _i > 0; _i--) { + var _ret = _loop(_i); + + if (_ret === "break") break; + } + } + + if (state.placement !== firstFittingPlacement) { + state.modifiersData[name]._skip = true; + state.placement = firstFittingPlacement; + state.reset = true; + } + } // eslint-disable-next-line import/no-unused-modules + + + var flip$1 = { + name: 'flip', + enabled: true, + phase: 'main', + fn: flip, + requiresIfExists: ['offset'], + data: { + _skip: false + } + }; + + function getAltAxis(axis) { + return axis === 'x' ? 'y' : 'x'; + } + + function within(min$1, value, max$1) { + return max(min$1, min(value, max$1)); + } + function withinMaxClamp(min, value, max) { + var v = within(min, value, max); + return v > max ? max : v; + } + + function preventOverflow(_ref) { + var state = _ref.state, + options = _ref.options, + name = _ref.name; + var _options$mainAxis = options.mainAxis, + checkMainAxis = _options$mainAxis === void 0 ? true : _options$mainAxis, + _options$altAxis = options.altAxis, + checkAltAxis = _options$altAxis === void 0 ? false : _options$altAxis, + boundary = options.boundary, + rootBoundary = options.rootBoundary, + altBoundary = options.altBoundary, + padding = options.padding, + _options$tether = options.tether, + tether = _options$tether === void 0 ? true : _options$tether, + _options$tetherOffset = options.tetherOffset, + tetherOffset = _options$tetherOffset === void 0 ? 0 : _options$tetherOffset; + var overflow = detectOverflow(state, { + boundary: boundary, + rootBoundary: rootBoundary, + padding: padding, + altBoundary: altBoundary + }); + var basePlacement = getBasePlacement(state.placement); + var variation = getVariation(state.placement); + var isBasePlacement = !variation; + var mainAxis = getMainAxisFromPlacement(basePlacement); + var altAxis = getAltAxis(mainAxis); + var popperOffsets = state.modifiersData.popperOffsets; + var referenceRect = state.rects.reference; + var popperRect = state.rects.popper; + var tetherOffsetValue = typeof tetherOffset === 'function' ? tetherOffset(Object.assign({}, state.rects, { + placement: state.placement + })) : tetherOffset; + var normalizedTetherOffsetValue = typeof tetherOffsetValue === 'number' ? { + mainAxis: tetherOffsetValue, + altAxis: tetherOffsetValue + } : Object.assign({ + mainAxis: 0, + altAxis: 0 + }, tetherOffsetValue); + var offsetModifierState = state.modifiersData.offset ? state.modifiersData.offset[state.placement] : null; + var data = { + x: 0, + y: 0 + }; + + if (!popperOffsets) { + return; + } + + if (checkMainAxis) { + var _offsetModifierState$; + + var mainSide = mainAxis === 'y' ? top : left; + var altSide = mainAxis === 'y' ? bottom : right; + var len = mainAxis === 'y' ? 'height' : 'width'; + var offset = popperOffsets[mainAxis]; + var min$1 = offset + overflow[mainSide]; + var max$1 = offset - overflow[altSide]; + var additive = tether ? -popperRect[len] / 2 : 0; + var minLen = variation === start ? referenceRect[len] : popperRect[len]; + var maxLen = variation === start ? -popperRect[len] : -referenceRect[len]; // We need to include the arrow in the calculation so the arrow doesn't go + // outside the reference bounds + + var arrowElement = state.elements.arrow; + var arrowRect = tether && arrowElement ? getLayoutRect(arrowElement) : { + width: 0, + height: 0 + }; + var arrowPaddingObject = state.modifiersData['arrow#persistent'] ? state.modifiersData['arrow#persistent'].padding : getFreshSideObject(); + var arrowPaddingMin = arrowPaddingObject[mainSide]; + var arrowPaddingMax = arrowPaddingObject[altSide]; // If the reference length is smaller than the arrow length, we don't want + // to include its full size in the calculation. If the reference is small + // and near the edge of a boundary, the popper can overflow even if the + // reference is not overflowing as well (e.g. virtual elements with no + // width or height) + + var arrowLen = within(0, referenceRect[len], arrowRect[len]); + var minOffset = isBasePlacement ? referenceRect[len] / 2 - additive - arrowLen - arrowPaddingMin - normalizedTetherOffsetValue.mainAxis : minLen - arrowLen - arrowPaddingMin - normalizedTetherOffsetValue.mainAxis; + var maxOffset = isBasePlacement ? -referenceRect[len] / 2 + additive + arrowLen + arrowPaddingMax + normalizedTetherOffsetValue.mainAxis : maxLen + arrowLen + arrowPaddingMax + normalizedTetherOffsetValue.mainAxis; + var arrowOffsetParent = state.elements.arrow && getOffsetParent(state.elements.arrow); + var clientOffset = arrowOffsetParent ? mainAxis === 'y' ? arrowOffsetParent.clientTop || 0 : arrowOffsetParent.clientLeft || 0 : 0; + var offsetModifierValue = (_offsetModifierState$ = offsetModifierState == null ? void 0 : offsetModifierState[mainAxis]) != null ? _offsetModifierState$ : 0; + var tetherMin = offset + minOffset - offsetModifierValue - clientOffset; + var tetherMax = offset + maxOffset - offsetModifierValue; + var preventedOffset = within(tether ? min(min$1, tetherMin) : min$1, offset, tether ? max(max$1, tetherMax) : max$1); + popperOffsets[mainAxis] = preventedOffset; + data[mainAxis] = preventedOffset - offset; + } + + if (checkAltAxis) { + var _offsetModifierState$2; + + var _mainSide = mainAxis === 'x' ? top : left; + + var _altSide = mainAxis === 'x' ? bottom : right; + + var _offset = popperOffsets[altAxis]; + + var _len = altAxis === 'y' ? 'height' : 'width'; + + var _min = _offset + overflow[_mainSide]; + + var _max = _offset - overflow[_altSide]; + + var isOriginSide = [top, left].indexOf(basePlacement) !== -1; + + var _offsetModifierValue = (_offsetModifierState$2 = offsetModifierState == null ? void 0 : offsetModifierState[altAxis]) != null ? _offsetModifierState$2 : 0; + + var _tetherMin = isOriginSide ? _min : _offset - referenceRect[_len] - popperRect[_len] - _offsetModifierValue + normalizedTetherOffsetValue.altAxis; + + var _tetherMax = isOriginSide ? _offset + referenceRect[_len] + popperRect[_len] - _offsetModifierValue - normalizedTetherOffsetValue.altAxis : _max; + + var _preventedOffset = tether && isOriginSide ? withinMaxClamp(_tetherMin, _offset, _tetherMax) : within(tether ? _tetherMin : _min, _offset, tether ? _tetherMax : _max); + + popperOffsets[altAxis] = _preventedOffset; + data[altAxis] = _preventedOffset - _offset; + } + + state.modifiersData[name] = data; + } // eslint-disable-next-line import/no-unused-modules + + + var preventOverflow$1 = { + name: 'preventOverflow', + enabled: true, + phase: 'main', + fn: preventOverflow, + requiresIfExists: ['offset'] + }; + + var toPaddingObject = function toPaddingObject(padding, state) { + padding = typeof padding === 'function' ? padding(Object.assign({}, state.rects, { + placement: state.placement + })) : padding; + return mergePaddingObject(typeof padding !== 'number' ? padding : expandToHashMap(padding, basePlacements)); + }; + + function arrow(_ref) { + var _state$modifiersData$; + + var state = _ref.state, + name = _ref.name, + options = _ref.options; + var arrowElement = state.elements.arrow; + var popperOffsets = state.modifiersData.popperOffsets; + var basePlacement = getBasePlacement(state.placement); + var axis = getMainAxisFromPlacement(basePlacement); + var isVertical = [left, right].indexOf(basePlacement) >= 0; + var len = isVertical ? 'height' : 'width'; + + if (!arrowElement || !popperOffsets) { + return; + } + + var paddingObject = toPaddingObject(options.padding, state); + var arrowRect = getLayoutRect(arrowElement); + var minProp = axis === 'y' ? top : left; + var maxProp = axis === 'y' ? bottom : right; + var endDiff = state.rects.reference[len] + state.rects.reference[axis] - popperOffsets[axis] - state.rects.popper[len]; + var startDiff = popperOffsets[axis] - state.rects.reference[axis]; + var arrowOffsetParent = getOffsetParent(arrowElement); + var clientSize = arrowOffsetParent ? axis === 'y' ? arrowOffsetParent.clientHeight || 0 : arrowOffsetParent.clientWidth || 0 : 0; + var centerToReference = endDiff / 2 - startDiff / 2; // Make sure the arrow doesn't overflow the popper if the center point is + // outside of the popper bounds + + var min = paddingObject[minProp]; + var max = clientSize - arrowRect[len] - paddingObject[maxProp]; + var center = clientSize / 2 - arrowRect[len] / 2 + centerToReference; + var offset = within(min, center, max); // Prevents breaking syntax highlighting... + + var axisProp = axis; + state.modifiersData[name] = (_state$modifiersData$ = {}, _state$modifiersData$[axisProp] = offset, _state$modifiersData$.centerOffset = offset - center, _state$modifiersData$); + } + + function effect(_ref2) { + var state = _ref2.state, + options = _ref2.options; + var _options$element = options.element, + arrowElement = _options$element === void 0 ? '[data-popper-arrow]' : _options$element; + + if (arrowElement == null) { + return; + } // CSS selector + + + if (typeof arrowElement === 'string') { + arrowElement = state.elements.popper.querySelector(arrowElement); + + if (!arrowElement) { + return; + } + } + + if (!contains(state.elements.popper, arrowElement)) { + return; + } + + state.elements.arrow = arrowElement; + } // eslint-disable-next-line import/no-unused-modules + + + var arrow$1 = { + name: 'arrow', + enabled: true, + phase: 'main', + fn: arrow, + effect: effect, + requires: ['popperOffsets'], + requiresIfExists: ['preventOverflow'] + }; + + function getSideOffsets(overflow, rect, preventedOffsets) { + if (preventedOffsets === void 0) { + preventedOffsets = { + x: 0, + y: 0 + }; + } + + return { + top: overflow.top - rect.height - preventedOffsets.y, + right: overflow.right - rect.width + preventedOffsets.x, + bottom: overflow.bottom - rect.height + preventedOffsets.y, + left: overflow.left - rect.width - preventedOffsets.x + }; + } + + function isAnySideFullyClipped(overflow) { + return [top, right, bottom, left].some(function (side) { + return overflow[side] >= 0; + }); + } + + function hide(_ref) { + var state = _ref.state, + name = _ref.name; + var referenceRect = state.rects.reference; + var popperRect = state.rects.popper; + var preventedOffsets = state.modifiersData.preventOverflow; + var referenceOverflow = detectOverflow(state, { + elementContext: 'reference' + }); + var popperAltOverflow = detectOverflow(state, { + altBoundary: true + }); + var referenceClippingOffsets = getSideOffsets(referenceOverflow, referenceRect); + var popperEscapeOffsets = getSideOffsets(popperAltOverflow, popperRect, preventedOffsets); + var isReferenceHidden = isAnySideFullyClipped(referenceClippingOffsets); + var hasPopperEscaped = isAnySideFullyClipped(popperEscapeOffsets); + state.modifiersData[name] = { + referenceClippingOffsets: referenceClippingOffsets, + popperEscapeOffsets: popperEscapeOffsets, + isReferenceHidden: isReferenceHidden, + hasPopperEscaped: hasPopperEscaped + }; + state.attributes.popper = Object.assign({}, state.attributes.popper, { + 'data-popper-reference-hidden': isReferenceHidden, + 'data-popper-escaped': hasPopperEscaped + }); + } // eslint-disable-next-line import/no-unused-modules + + + var hide$1 = { + name: 'hide', + enabled: true, + phase: 'main', + requiresIfExists: ['preventOverflow'], + fn: hide + }; + + var defaultModifiers$1 = [eventListeners, popperOffsets$1, computeStyles$1, applyStyles$1]; + var createPopper$1 = /*#__PURE__*/popperGenerator({ + defaultModifiers: defaultModifiers$1 + }); // eslint-disable-next-line import/no-unused-modules + + var defaultModifiers = [eventListeners, popperOffsets$1, computeStyles$1, applyStyles$1, offset$1, flip$1, preventOverflow$1, arrow$1, hide$1]; + var createPopper = /*#__PURE__*/popperGenerator({ + defaultModifiers: defaultModifiers + }); // eslint-disable-next-line import/no-unused-modules + + exports.applyStyles = applyStyles$1; + exports.arrow = arrow$1; + exports.computeStyles = computeStyles$1; + exports.createPopper = createPopper; + exports.createPopperLite = createPopper$1; + exports.defaultModifiers = defaultModifiers; + exports.detectOverflow = detectOverflow; + exports.eventListeners = eventListeners; + exports.flip = flip$1; + exports.hide = hide$1; + exports.offset = offset$1; + exports.popperGenerator = popperGenerator; + exports.popperOffsets = popperOffsets$1; + exports.preventOverflow = preventOverflow$1; + + Object.defineProperty(exports, '__esModule', { value: true }); + +}))); +//# sourceMappingURL=popper.js.map diff --git a/frontend/web/static/lib/prismjs/themes/default.css b/frontend/web/static/lib/prismjs/themes/default.css new file mode 100644 index 0000000..b0d1f17 --- /dev/null +++ b/frontend/web/static/lib/prismjs/themes/default.css @@ -0,0 +1,142 @@ +/* PrismJS 1.30.0 +https://prismjs.com/download#themes=prism&languages=markup+css+clike+javascript+diff+java+javadoclike+jsdoc+json+markdown+python+sass+scss+sql+typescript */ +/** + * prism.js default theme for JavaScript, CSS and HTML + * Based on dabblet (http://dabblet.com) + * @author Lea Verou + */ + +code[class*="language-"], +pre[class*="language-"] { + color: black; + background: none; + text-shadow: 0 1px white; + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + font-size: 1em; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} + +pre[class*="language-"]::-moz-selection, pre[class*="language-"] ::-moz-selection, +code[class*="language-"]::-moz-selection, code[class*="language-"] ::-moz-selection { + text-shadow: none; + background: #b3d4fc; +} + +pre[class*="language-"]::selection, pre[class*="language-"] ::selection, +code[class*="language-"]::selection, code[class*="language-"] ::selection { + text-shadow: none; + background: #b3d4fc; +} + +@media print { + code[class*="language-"], + pre[class*="language-"] { + text-shadow: none; + } +} + +/* Code blocks */ +pre[class*="language-"] { + padding: 1em; + margin: .5em 0; + overflow: auto; +} + +:not(pre) > code[class*="language-"], +pre[class*="language-"] { + background: #f5f2f0; +} + +/* Inline code */ +:not(pre) > code[class*="language-"] { + padding: .1em; + border-radius: .3em; + white-space: normal; +} + +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: slategray; +} + +.token.punctuation { + color: #999; +} + +.token.namespace { + opacity: .7; +} + +.token.property, +.token.tag, +.token.boolean, +.token.number, +.token.constant, +.token.symbol, +.token.deleted { + color: #905; +} + +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.inserted { + color: #690; +} + +.token.operator, +.token.entity, +.token.url, +.language-css .token.string, +.style .token.string { + color: #9a6e3a; + /* This background color was intended by the author of this theme. */ + background: hsla(0, 0%, 100%, .5); +} + +.token.atrule, +.token.attr-value, +.token.keyword { + color: #07a; +} + +.token.function, +.token.class-name { + color: #DD4A68; +} + +.token.regex, +.token.important, +.token.variable { + color: #e90; +} + +.token.important, +.token.bold { + font-weight: bold; +} +.token.italic { + font-style: italic; +} + +.token.entity { + cursor: help; +} diff --git a/frontend/web/static/lib/prismjs/themes/okaida.css b/frontend/web/static/lib/prismjs/themes/okaida.css new file mode 100644 index 0000000..62c4beb --- /dev/null +++ b/frontend/web/static/lib/prismjs/themes/okaida.css @@ -0,0 +1,125 @@ +/* PrismJS 1.30.0 +https://prismjs.com/download#themes=prism-okaidia&languages=markup+css+clike+javascript+diff+java+javadoclike+jsdoc+json+markdown+python+sass+scss+sql+typescript */ +/** + * okaidia theme for JavaScript, CSS and HTML + * Loosely based on Monokai textmate theme by http://www.monokai.nl/ + * @author ocodia + */ + +code[class*="language-"], +pre[class*="language-"] { + color: #f8f8f2; + background: none; + text-shadow: 0 1px rgba(0, 0, 0, 0.3); + font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; + font-size: 1em; + text-align: left; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; + line-height: 1.5; + + -moz-tab-size: 4; + -o-tab-size: 4; + tab-size: 4; + + -webkit-hyphens: none; + -moz-hyphens: none; + -ms-hyphens: none; + hyphens: none; +} + +/* Code blocks */ +pre[class*="language-"] { + padding: 1em; + margin: .5em 0; + overflow: auto; + border-radius: 0.3em; +} + +:not(pre) > code[class*="language-"], +pre[class*="language-"] { + background: #272822; +} + +/* Inline code */ +:not(pre) > code[class*="language-"] { + padding: .1em; + border-radius: .3em; + white-space: normal; +} + +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: #8292a2; +} + +.token.punctuation { + color: #f8f8f2; +} + +.token.namespace { + opacity: .7; +} + +.token.property, +.token.tag, +.token.constant, +.token.symbol, +.token.deleted { + color: #f92672; +} + +.token.boolean, +.token.number { + color: #ae81ff; +} + +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.inserted { + color: #a6e22e; +} + +.token.operator, +.token.entity, +.token.url, +.language-css .token.string, +.style .token.string, +.token.variable { + color: #f8f8f2; +} + +.token.atrule, +.token.attr-value, +.token.function, +.token.class-name { + color: #e6db74; +} + +.token.keyword { + color: #66d9ef; +} + +.token.regex, +.token.important { + color: #fd971f; +} + +.token.important, +.token.bold { + font-weight: bold; +} +.token.italic { + font-style: italic; +} + +.token.entity { + cursor: help; +} diff --git a/frontend/web/static/lib/qunit/qunit-2.9.1.css b/frontend/web/static/lib/qunit/qunit-2.9.1.css new file mode 100644 index 0000000..4e99a39 --- /dev/null +++ b/frontend/web/static/lib/qunit/qunit-2.9.1.css @@ -0,0 +1,436 @@ +/*! + * QUnit 2.9.1 + * https://qunitjs.com/ + * + * Copyright jQuery Foundation and other contributors + * Released under the MIT license + * https://jquery.org/license + * + * Date: 2019-01-07T16:37Z + */ + +/** Font Family and Sizes */ + +#qunit-tests, #qunit-header, #qunit-banner, #qunit-testrunner-toolbar, #qunit-filteredTest, #qunit-userAgent, #qunit-testresult { + font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif; +} + +#qunit-testrunner-toolbar, #qunit-filteredTest, #qunit-userAgent, #qunit-testresult, #qunit-tests li { font-size: small; } +#qunit-tests { font-size: smaller; } + + +/** Resets */ + +#qunit-tests, #qunit-header, #qunit-banner, #qunit-filteredTest, #qunit-userAgent, #qunit-testresult, #qunit-modulefilter { + margin: 0; + padding: 0; +} + + +/** Header (excluding toolbar) */ + +#qunit-header { + padding: 0.5em 0 0.5em 1em; + + color: #8699A4; + background-color: #0D3349; + + font-size: 1.5em; + line-height: 1em; + font-weight: 400; + + border-radius: 5px 5px 0 0; +} + +#qunit-header a { + text-decoration: none; + color: #C2CCD1; +} + +#qunit-header a:hover, +#qunit-header a:focus { + color: #FFF; +} + +#qunit-banner { + height: 5px; +} + +#qunit-filteredTest { + padding: 0.5em 1em 0.5em 1em; + color: #366097; + background-color: #F4FF77; +} + +#qunit-userAgent { + padding: 0.5em 1em 0.5em 1em; + color: #FFF; + background-color: #2B81AF; + text-shadow: rgba(0, 0, 0, 0.5) 2px 2px 1px; +} + + +/** Toolbar */ + +#qunit-testrunner-toolbar { + padding: 0.5em 1em 0.5em 1em; + color: #5E740B; + background-color: #EEE; +} + +#qunit-testrunner-toolbar .clearfix { + height: 0; + clear: both; +} + +#qunit-testrunner-toolbar label { + display: inline-block; +} + +#qunit-testrunner-toolbar input[type=checkbox], +#qunit-testrunner-toolbar input[type=radio] { + margin: 3px; + vertical-align: -2px; +} + +#qunit-testrunner-toolbar input[type=text] { + box-sizing: border-box; + height: 1.6em; +} + +.qunit-url-config, +.qunit-filter, +#qunit-modulefilter { + display: inline-block; + line-height: 2.1em; +} + +.qunit-filter, +#qunit-modulefilter { + float: right; + position: relative; + margin-left: 1em; +} + +.qunit-url-config label { + margin-right: 0.5em; +} + +#qunit-modulefilter-search { + box-sizing: border-box; + width: 400px; +} + +#qunit-modulefilter-search-container:after { + position: absolute; + right: 0.3em; + content: "\25bc"; + color: black; +} + +#qunit-modulefilter-dropdown { + /* align with #qunit-modulefilter-search */ + box-sizing: border-box; + width: 400px; + position: absolute; + right: 0; + top: 50%; + margin-top: 0.8em; + + border: 1px solid #D3D3D3; + border-top: none; + border-radius: 0 0 .25em .25em; + color: #000; + background-color: #F5F5F5; + z-index: 99; +} + +#qunit-modulefilter-dropdown a { + color: inherit; + text-decoration: none; +} + +#qunit-modulefilter-dropdown .clickable.checked { + font-weight: bold; + color: #000; + background-color: #D2E0E6; +} + +#qunit-modulefilter-dropdown .clickable:hover { + color: #FFF; + background-color: #0D3349; +} + +#qunit-modulefilter-actions { + display: block; + overflow: auto; + + /* align with #qunit-modulefilter-dropdown-list */ + font: smaller/1.5em sans-serif; +} + +#qunit-modulefilter-dropdown #qunit-modulefilter-actions > * { + box-sizing: border-box; + max-height: 2.8em; + display: block; + padding: 0.4em; +} + +#qunit-modulefilter-dropdown #qunit-modulefilter-actions > button { + float: right; + font: inherit; +} + +#qunit-modulefilter-dropdown #qunit-modulefilter-actions > :last-child { + /* insert padding to align with checkbox margins */ + padding-left: 3px; +} + +#qunit-modulefilter-dropdown-list { + max-height: 200px; + overflow-y: auto; + margin: 0; + border-top: 2px groove threedhighlight; + padding: 0.4em 0 0; + font: smaller/1.5em sans-serif; +} + +#qunit-modulefilter-dropdown-list li { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +#qunit-modulefilter-dropdown-list .clickable { + display: block; + padding-left: 0.15em; +} + + +/** Tests: Pass/Fail */ + +#qunit-tests { + list-style-position: inside; +} + +#qunit-tests li { + padding: 0.4em 1em 0.4em 1em; + border-bottom: 1px solid #FFF; + list-style-position: inside; +} + +#qunit-tests > li { + display: none; +} + +#qunit-tests li.running, +#qunit-tests li.pass, +#qunit-tests li.fail, +#qunit-tests li.skipped, +#qunit-tests li.aborted { + display: list-item; +} + +#qunit-tests.hidepass { + position: relative; +} + +#qunit-tests.hidepass li.running, +#qunit-tests.hidepass li.pass:not(.todo) { + visibility: hidden; + position: absolute; + width: 0; + height: 0; + padding: 0; + border: 0; + margin: 0; +} + +#qunit-tests li strong { + cursor: pointer; +} + +#qunit-tests li.skipped strong { + cursor: default; +} + +#qunit-tests li a { + padding: 0.5em; + color: #C2CCD1; + text-decoration: none; +} + +#qunit-tests li p a { + padding: 0.25em; + color: #6B6464; +} +#qunit-tests li a:hover, +#qunit-tests li a:focus { + color: #000; +} + +#qunit-tests li .runtime { + float: right; + font-size: smaller; +} + +.qunit-assert-list { + margin-top: 0.5em; + padding: 0.5em; + + background-color: #FFF; + + border-radius: 5px; +} + +.qunit-source { + margin: 0.6em 0 0.3em; +} + +.qunit-collapsed { + display: none; +} + +#qunit-tests table { + border-collapse: collapse; + margin-top: 0.2em; +} + +#qunit-tests th { + text-align: right; + vertical-align: top; + padding: 0 0.5em 0 0; +} + +#qunit-tests td { + vertical-align: top; +} + +#qunit-tests pre { + margin: 0; + white-space: pre-wrap; + word-wrap: break-word; +} + +#qunit-tests del { + color: #374E0C; + background-color: #E0F2BE; + text-decoration: none; +} + +#qunit-tests ins { + color: #500; + background-color: #FFCACA; + text-decoration: none; +} + +/*** Test Counts */ + +#qunit-tests b.counts { color: #000; } +#qunit-tests b.passed { color: #5E740B; } +#qunit-tests b.failed { color: #710909; } + +#qunit-tests li li { + padding: 5px; + background-color: #FFF; + border-bottom: none; + list-style-position: inside; +} + +/*** Passing Styles */ + +#qunit-tests li li.pass { + color: #3C510C; + background-color: #FFF; + border-left: 10px solid #C6E746; +} + +#qunit-tests .pass { color: #528CE0; background-color: #D2E0E6; } +#qunit-tests .pass .test-name { color: #366097; } + +#qunit-tests .pass .test-actual, +#qunit-tests .pass .test-expected { color: #999; } + +#qunit-banner.qunit-pass { background-color: #C6E746; } + +/*** Failing Styles */ + +#qunit-tests li li.fail { + color: #710909; + background-color: #FFF; + border-left: 10px solid #EE5757; + white-space: pre; +} + +#qunit-tests > li:last-child { + border-radius: 0 0 5px 5px; +} + +#qunit-tests .fail { color: #000; background-color: #EE5757; } +#qunit-tests .fail .test-name, +#qunit-tests .fail .module-name { color: #000; } + +#qunit-tests .fail .test-actual { color: #EE5757; } +#qunit-tests .fail .test-expected { color: #008000; } + +#qunit-banner.qunit-fail { background-color: #EE5757; } + + +/*** Aborted tests */ +#qunit-tests .aborted { color: #000; background-color: orange; } +/*** Skipped tests */ + +#qunit-tests .skipped { + background-color: #EBECE9; +} + +#qunit-tests .qunit-todo-label, +#qunit-tests .qunit-skipped-label { + background-color: #F4FF77; + display: inline-block; + font-style: normal; + color: #366097; + line-height: 1.8em; + padding: 0 0.5em; + margin: -0.4em 0.4em -0.4em 0; +} + +#qunit-tests .qunit-todo-label { + background-color: #EEE; +} + +/** Result */ + +#qunit-testresult { + color: #2B81AF; + background-color: #D2E0E6; + + border-bottom: 1px solid #FFF; +} +#qunit-testresult .clearfix { + height: 0; + clear: both; +} +#qunit-testresult .module-name { + font-weight: 700; +} +#qunit-testresult-display { + padding: 0.5em 1em 0.5em 1em; + width: 85%; + float:left; +} +#qunit-testresult-controls { + padding: 0.5em 1em 0.5em 1em; + width: 10%; + float:left; +} + +/** Fixture */ + +#qunit-fixture { + position: absolute; + top: -10000px; + left: -10000px; + width: 1000px; + height: 1000px; +} diff --git a/frontend/web/static/lib/stacktracejs/LICENSE b/frontend/web/static/lib/stacktracejs/LICENSE new file mode 100644 index 0000000..fb96178 --- /dev/null +++ b/frontend/web/static/lib/stacktracejs/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2017 Eric Wendelin and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/frontend/web/static/lib/zxing-library/LICENSE b/frontend/web/static/lib/zxing-library/LICENSE new file mode 100644 index 0000000..4bcbdae --- /dev/null +++ b/frontend/web/static/lib/zxing-library/LICENSE @@ -0,0 +1,245 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +======================================================================== +jai-imageio +======================================================================== + +Copyright (c) 2005 Sun Microsystems, Inc. +Copyright © 2010-2014 University of Manchester +Copyright © 2010-2015 Stian Soiland-Reyes +Copyright © 2015 Peter Hull +All Rights Reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + +- Redistribution of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +- Redistribution in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + +Neither the name of Sun Microsystems, Inc. or the names of +contributors may be used to endorse or promote products derived +from this software without specific prior written permission. + +This software is provided "AS IS," without a warranty of any +kind. ALL EXPRESS OR IMPLIED CONDITIONS, REPRESENTATIONS AND +WARRANTIES, INCLUDING ANY IMPLIED WARRANTY OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE OR NON-INFRINGEMENT, ARE HEREBY +EXCLUDED. SUN MIDROSYSTEMS, INC. ("SUN") AND ITS LICENSORS SHALL +NOT BE LIABLE FOR ANY DAMAGES SUFFERED BY LICENSEE AS A RESULT OF +USING, MODIFYING OR DISTRIBUTING THIS SOFTWARE OR ITS +DERIVATIVES. IN NO EVENT WILL SUN OR ITS LICENSORS BE LIABLE FOR +ANY LOST REVENUE, PROFIT OR DATA, OR FOR DIRECT, INDIRECT, SPECIAL, +CONSEQUENTIAL, INCIDENTAL OR PUNITIVE DAMAGES, HOWEVER CAUSED AND +REGARDLESS OF THE THEORY OF LIABILITY, ARISING OUT OF THE USE OF OR +INABILITY TO USE THIS SOFTWARE, EVEN IF SUN HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + +You acknowledge that this software is not designed or intended for +use in the design, construction, operation or maintenance of any +nuclear facility. diff --git a/frontend/web/static/lib/zxing-library/version b/frontend/web/static/lib/zxing-library/version new file mode 100644 index 0000000..16eb94e --- /dev/null +++ b/frontend/web/static/lib/zxing-library/version @@ -0,0 +1 @@ +0.21.3 diff --git a/frontend/web/static/src/core/action_swiper/action_swiper.js b/frontend/web/static/src/core/action_swiper/action_swiper.js new file mode 100644 index 0000000..138726a --- /dev/null +++ b/frontend/web/static/src/core/action_swiper/action_swiper.js @@ -0,0 +1,225 @@ +import { browser } from "@web/core/browser/browser"; +import { localization } from "@web/core/l10n/localization"; +import { clamp } from "@web/core/utils/numbers"; + +import { Component, onMounted, onWillUnmount, useRef, useState } from "@odoo/owl"; +import { Deferred } from "@web/core/utils/concurrency"; + +const isScrollSwipable = (scrollables) => { + return { + left: !scrollables.filter((e) => e.scrollLeft !== 0).length, + right: !scrollables.filter( + (e) => e.scrollLeft + Math.round(e.getBoundingClientRect().width) !== e.scrollWidth + ).length, + }; +}; + +/** + * Action Swiper + * + * This component is intended to perform action once a user has completed a touch swipe. + * You can choose the direction allowed for such behavior (left, right or both). + * The action to perform must be passed as a props. It is possible to define a condition + * to allow the swipe interaction conditionnally. + * @extends Component + */ +export class ActionSwiper extends Component { + static template = "web.ActionSwiper"; + static props = { + onLeftSwipe: { + type: Object, + args: { + action: Function, + icon: String, + bgColor: String, + }, + optional: true, + }, + onRightSwipe: { + type: Object, + args: { + action: Function, + icon: String, + bgColor: String, + }, + optional: true, + }, + slots: Object, + animationOnMove: { type: Boolean, optional: true }, + animationType: { type: String, optional: true }, + swipeDistanceRatio: { type: Number, optional: true }, + swipeInvalid: { type: Function, optional: true }, + }; + + static defaultProps = { + onLeftSwipe: undefined, + onRightSwipe: undefined, + animationOnMove: true, + animationType: "bounce", + swipeDistanceRatio: 2, + }; + + setup() { + this.actionTimeoutId = null; + this.resetTimeoutId = null; + this.defaultState = { + containerStyle: "", + isSwiping: false, + width: undefined, + }; + this.root = useRef("root"); + this.targetContainer = useRef("targetContainer"); + this.state = useState({ ...this.defaultState }); + this.scrollables = undefined; + this.startX = undefined; + this.swipedDistance = 0; + this.isScrollValidated = false; + onMounted(() => { + if (this.targetContainer.el) { + this.state.width = this.targetContainer.el.getBoundingClientRect().width; + } + // Forward classes set on component to slot, as we only want to wrap an + // existing component without altering the DOM structure any more than + // strictly necessary + if (this.props.onLeftSwipe || this.props.onRightSwipe) { + const classes = new Set(this.root.el.classList); + classes.delete("o_actionswiper"); + for (const className of classes) { + this.targetContainer.el.firstChild.classList.add(className); + this.root.el.classList.remove(className); + } + } + }); + onWillUnmount(() => { + browser.clearTimeout(this.actionTimeoutId); + browser.clearTimeout(this.resetTimeoutId); + }); + } + get localizedProps() { + return { + onLeftSwipe: + localization.direction === "rtl" ? this.props.onRightSwipe : this.props.onLeftSwipe, + onRightSwipe: + localization.direction === "rtl" ? this.props.onLeftSwipe : this.props.onRightSwipe, + }; + } + + /** + * @private + * @param {TouchEvent} ev + */ + _onTouchEndSwipe() { + if (this.state.isSwiping) { + this.state.isSwiping = false; + if ( + this.localizedProps.onRightSwipe && + this.swipedDistance > this.state.width / this.props.swipeDistanceRatio + ) { + this.swipedDistance = this.state.width; + this.handleSwipe(this.localizedProps.onRightSwipe.action); + } else if ( + this.localizedProps.onLeftSwipe && + this.swipedDistance < -this.state.width / this.props.swipeDistanceRatio + ) { + this.swipedDistance = -this.state.width; + this.handleSwipe(this.localizedProps.onLeftSwipe.action); + } else { + this.state.containerStyle = ""; + } + } + } + /** + * @private + * @param {TouchEvent} ev + */ + _onTouchMoveSwipe(ev) { + if (this.state.isSwiping) { + if (this.props.swipeInvalid && this.props.swipeInvalid()) { + this.state.isSwiping = false; + return; + } + const { onLeftSwipe, onRightSwipe } = this.localizedProps; + this.swipedDistance = clamp( + ev.touches[0].clientX - this.startX, + onLeftSwipe ? -this.state.width : 0, + onRightSwipe ? this.state.width : 0 + ); + // Prevent the browser to navigate back/forward when using swipe + // gestures while still allowing to scroll vertically. + if (Math.abs(this.swipedDistance) > 40) { + ev.preventDefault(); + } + // If there are scrollable elements under touch pressure, + // they must be at their limits to allow swiping. + if ( + !this.isScrollValidated && + this.scrollables && + !isScrollSwipable(this.scrollables)[this.swipedDistance > 0 ? "left" : "right"] + ) { + return this._reset(); + } + this.isScrollValidated = true; + + if (this.props.animationOnMove) { + this.state.containerStyle = `transform: translateX(${this.swipedDistance}px)`; + } + } + } + /** + * @private + * @param {TouchEvent} ev + */ + _onTouchStartSwipe(ev) { + this.scrollables = ev + .composedPath() + .filter( + (e) => + e.nodeType === 1 && + this.targetContainer.el.contains(e) && + e.scrollWidth > e.getBoundingClientRect().width && + ["auto", "scroll"].includes(window.getComputedStyle(e)["overflow-x"]) + ); + if (!this.state.width) { + this.state.width = + this.targetContainer && this.targetContainer.el.getBoundingClientRect().width; + } + this.state.isSwiping = true; + this.isScrollValidated = false; + this.startX = ev.touches[0].clientX; + } + + /** + * @private + */ + _reset() { + Object.assign(this.state, { ...this.defaultState }); + this.scrollables = undefined; + this.startX = undefined; + this.swipedDistance = 0; + this.isScrollValidated = false; + } + + handleSwipe(action) { + if (this.props.animationType === "bounce") { + this.state.containerStyle = `transform: translateX(${this.swipedDistance}px)`; + this.actionTimeoutId = browser.setTimeout(async () => { + await action(Promise.resolve()); + this._reset(); + }, 500); + } else if (this.props.animationType === "forwards") { + this.state.containerStyle = `transform: translateX(${this.swipedDistance}px)`; + this.actionTimeoutId = browser.setTimeout(async () => { + const prom = new Deferred(); + await action(prom); + this.state.isSwiping = true; + this.state.containerStyle = `transform: translateX(${-this.swipedDistance}px)`; + this.resetTimeoutId = browser.setTimeout(() => { + prom.resolve(); + this._reset(); + }, 100); + }, 100); + } else { + return action(Promise.resolve()); + } + } +} diff --git a/frontend/web/static/src/core/action_swiper/action_swiper.scss b/frontend/web/static/src/core/action_swiper/action_swiper.scss new file mode 100644 index 0000000..b681dc3 --- /dev/null +++ b/frontend/web/static/src/core/action_swiper/action_swiper.scss @@ -0,0 +1,20 @@ +.o_actionswiper { + position: relative; + touch-action: pan-y; +} +.o_actionswiper_target_container { + transition: transform 0.4s; +} +.o_actionswiper_swiping { + transition: none; +} +.o_actionswiper_right_swipe_area { + /*rtl:ignore*/ + transform: translateX(-100%); + inset: 0 auto auto 0; +} +.o_actionswiper_left_swipe_area { + /*rtl:ignore*/ + transform: translateX(100%); + inset: 0 0 auto auto; +} diff --git a/frontend/web/static/src/core/action_swiper/action_swiper.xml b/frontend/web/static/src/core/action_swiper/action_swiper.xml new file mode 100644 index 0000000..b2281aa --- /dev/null +++ b/frontend/web/static/src/core/action_swiper/action_swiper.xml @@ -0,0 +1,27 @@ + + + + +
+
+
+ + +
+ +
+
+ +
+ +
+
+
+
+
+
+ + + + +
diff --git a/frontend/web/static/src/core/anchor_scroll_prevention.js b/frontend/web/static/src/core/anchor_scroll_prevention.js new file mode 100644 index 0000000..81b99bc --- /dev/null +++ b/frontend/web/static/src/core/anchor_scroll_prevention.js @@ -0,0 +1,9 @@ +import { browser } from "./browser/browser"; + +browser.addEventListener("click", (ev) => { + const href = ev.target.closest("a")?.getAttribute("href"); + if (href && href === "#") { + ev.preventDefault(); // single hash in href are just a way to activate A-tags node + return; + } +}); diff --git a/frontend/web/static/src/core/assets.js b/frontend/web/static/src/core/assets.js new file mode 100644 index 0000000..5206828 --- /dev/null +++ b/frontend/web/static/src/core/assets.js @@ -0,0 +1,262 @@ +import { Component, onWillStart, whenReady, xml } from "@odoo/owl"; +import { session } from "@web/session"; +import { registry } from "./registry"; + +/** + * @typedef {{ + * cssLibs: string[]; + * jsLibs: string[]; + * }} BundleFileNames + */ + +export const globalBundleCache = new Map(); +export const assetCacheByDocument = new WeakMap(); + +function getGlobalBundleCache() { + return globalBundleCache; +} + +function getAssetCache(targetDoc) { + if (!assetCacheByDocument.has(targetDoc)) { + assetCacheByDocument.set(targetDoc, new Map()); + } + return assetCacheByDocument.get(targetDoc); +} + +export function computeBundleCacheMap(targetDoc) { + const cacheMap = getGlobalBundleCache(); + for (const script of targetDoc.head.querySelectorAll("script[src]")) { + cacheMap.set(script.getAttribute("src"), Promise.resolve()); + } + for (const link of targetDoc.head.querySelectorAll("link[rel=stylesheet][href]")) { + cacheMap.set(link.getAttribute("href"), Promise.resolve()); + } +} + +whenReady(() => computeBundleCacheMap(document)); + +/** + * @param {HTMLLinkElement | HTMLScriptElement} el + * @param {(event: Event) => any} onLoad + * @param {(error: Error) => any} onError + */ +const onLoadAndError = (el, onLoad, onError) => { + const onLoadListener = (event) => { + removeListeners(); + onLoad(event); + }; + + const onErrorListener = (error) => { + removeListeners(); + onError(error); + }; + + const removeListeners = () => { + el.removeEventListener("load", onLoadListener); + el.removeEventListener("error", onErrorListener); + }; + + el.addEventListener("load", onLoadListener); + el.addEventListener("error", onErrorListener); + + window.addEventListener("pagehide", () => { + removeListeners(); + }); +}; + +/** @type {typeof assets["getBundle"]} */ +export function getBundle() { + return assets.getBundle(...arguments); +} + +/** @type {typeof assets["loadBundle"]} */ +export function loadBundle() { + return assets.loadBundle(...arguments); +} + +/** @type {typeof assets["loadJS"]} */ +export function loadJS() { + return assets.loadJS(...arguments); +} + +/** @type {typeof assets["loadCSS"]} */ +export function loadCSS() { + return assets.loadCSS(...arguments); +} + +export class AssetsLoadingError extends Error {} + +/** + * Utility component that loads an asset bundle before instanciating a component + */ +export class LazyComponent extends Component { + static template = xml``; + static props = { + Component: String, + bundle: String, + props: { type: [Object, Function], optional: true }, + }; + setup() { + onWillStart(async () => { + await loadBundle(this.props.bundle); + this.Component = registry.category("lazy_components").get(this.props.Component); + }); + } + + get componentProps() { + return typeof this.props.props === "function" ? this.props.props() : this.props.props; + } +} + +/** + * This export is done only in order to modify the behavior of the exported + * functions. This is done in order to be able to make a test environment. + * Modules should only use the methods exported below. + */ +export const assets = { + retries: { + count: 3, + delay: 5000, + extraDelay: 2500, + }, + + /** + * Get the files information as descriptor object from a public asset template. + * + * @param {string} bundleName Name of the bundle containing the list of files + * @returns {Promise} + */ + getBundle(bundleName) { + const cacheMap = getGlobalBundleCache(); + if (cacheMap.has(bundleName)) { + return cacheMap.get(bundleName); + } + const url = new URL(`/web/bundle/${bundleName}`, location.origin); + for (const [key, value] of Object.entries(session.bundle_params || {})) { + url.searchParams.set(key, value); + } + const promise = fetch(url) + .then(async (response) => { + const cssLibs = []; + const jsLibs = []; + if (!response.bodyUsed) { + const result = await response.json(); + for (const { src, type } of Object.values(result)) { + if (type === "link" && src) { + cssLibs.push(src); + } else if (type === "script" && src) { + jsLibs.push(src); + } + } + } + return { cssLibs, jsLibs }; + }) + .catch((reason) => { + cacheMap.delete(bundleName); + throw new AssetsLoadingError(`The loading of ${url} failed`, { cause: reason }); + }); + cacheMap.set(bundleName, promise); + return promise; + }, + + /** + * Loads the given js/css libraries and asset bundles. Note that no library or + * asset will be loaded if it was already done before. + * + * @param {string} bundleName + * @param {Object} options + * @param {Document} [options.targetDoc=document] document to which the bundle will be applied (e.g. iframe document) + * @param {Boolean} [options.css=true] apply bundle css on targetDoc + * @param {Boolean} [options.js=true] apply bundle js on targetDoc + * @returns {Promise} + */ + loadBundle(bundleName, { targetDoc = document, css = true, js = true } = {}) { + if (typeof bundleName !== "string") { + throw new Error( + `loadBundle(bundleName:string) accepts only bundleName argument as a string ! Not ${JSON.stringify( + bundleName + )} as ${typeof bundleName}` + ); + } + return getBundle(bundleName).then(({ cssLibs, jsLibs }) => { + const promises = []; + if (css && cssLibs) { + promises.push(...cssLibs.map((url) => assets.loadCSS(url, { targetDoc }))); + } + if (js && jsLibs) { + promises.push(...jsLibs.map((url) => assets.loadJS(url, { targetDoc }))); + } + return Promise.all(promises); + }); + }, + + /** + * Loads the given url as a stylesheet. + * + * @param {string} url the url of the stylesheet + * @param {number} [retryCount] + * @param {Object} options + * @param {number} [retryCount] + * @param {Document} [options.targetDoc=document] document to which the bundle will be applied (e.g. iframe document) + * @returns {Promise} resolved when the stylesheet has been loaded + */ + loadCSS(url, { retryCount = 0, targetDoc = document } = {}) { + const cacheMap = getAssetCache(targetDoc); + if (cacheMap.has(url)) { + return cacheMap.get(url); + } + const linkEl = targetDoc.createElement("link"); + linkEl.setAttribute("href", url); + linkEl.type = "text/css"; + linkEl.rel = "stylesheet"; + const promise = new Promise((resolve, reject) => + onLoadAndError(linkEl, resolve, async (error) => { + cacheMap.delete(url); + if (retryCount < assets.retries.count) { + const delay = assets.retries.delay + assets.retries.extraDelay * retryCount; + await new Promise((res) => setTimeout(res, delay)); + linkEl.remove(); + loadCSS(url, { retryCount: retryCount + 1, targetDoc }) + .then(resolve) + .catch((reason) => { + cacheMap.delete(url); + reject(reason); + }); + } else { + reject( + new AssetsLoadingError(`The loading of ${url} failed`, { cause: error }) + ); + } + }) + ); + cacheMap.set(url, promise); + targetDoc.head.appendChild(linkEl); + return promise; + }, + + /** + * Loads the given url inside a script tag. + * + * @param {string} url the url of the script + * @param {Document} targetDoc document to which the bundle will be applied (e.g. iframe document) + * @returns {Promise} resolved when the script has been loaded + */ + loadJS(url, { targetDoc = document } = {}) { + const cacheMap = getAssetCache(targetDoc); + if (cacheMap.has(url)) { + return cacheMap.get(url); + } + const scriptEl = targetDoc.createElement("script"); + scriptEl.setAttribute("src", url); + scriptEl.type = url.includes("web/static/lib/pdfjs/") ? "module" : "text/javascript"; + const promise = new Promise((resolve, reject) => + onLoadAndError(scriptEl, resolve, (error) => { + cacheMap.delete(url); + reject(new AssetsLoadingError(`The loading of ${url} failed`, { cause: error })); + }) + ); + cacheMap.set(url, promise); + targetDoc.head.appendChild(scriptEl); + return promise; + }, +}; diff --git a/frontend/web/static/src/core/autocomplete/autocomplete.js b/frontend/web/static/src/core/autocomplete/autocomplete.js new file mode 100644 index 0000000..1dc6b0b --- /dev/null +++ b/frontend/web/static/src/core/autocomplete/autocomplete.js @@ -0,0 +1,501 @@ +import { Deferred } from "@web/core/utils/concurrency"; +import { useAutofocus, useForwardRefToParent, useService } from "@web/core/utils/hooks"; +import { isScrollableY, scrollTo } from "@web/core/utils/scrolling"; +import { useDebounced } from "@web/core/utils/timing"; +import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service"; +import { usePosition } from "@web/core/position/position_hook"; +import { Component, onWillUpdateProps, useExternalListener, useRef, useState } from "@odoo/owl"; +import { mergeClasses } from "@web/core/utils/classname"; + +export class AutoComplete extends Component { + static template = "web.AutoComplete"; + static props = { + value: { type: String, optional: true }, + id: { type: String, optional: true }, + sources: { + type: Array, + element: { + type: Object, + shape: { + placeholder: { type: String, optional: true }, + options: [Array, Function], + optionSlot: { type: String, optional: true }, + }, + }, + }, + placeholder: { type: String, optional: true }, + title: { type: String, optional: true }, + autocomplete: { type: String, optional: true }, + autoSelect: { type: Boolean, optional: true }, + resetOnSelect: { type: Boolean, optional: true }, + onInput: { type: Function, optional: true }, + onCancel: { type: Function, optional: true }, + onChange: { type: Function, optional: true }, + onBlur: { type: Function, optional: true }, + onFocus: { type: Function, optional: true }, + searchOnInputClick: { type: Boolean, optional: true }, + input: { type: Function, optional: true }, + inputDebounceDelay: { type: Number, optional: true }, + dropdown: { type: Boolean, optional: true }, + autofocus: { type: Boolean, optional: true }, + class: { type: String, optional: true }, + slots: { type: Object, optional: true }, + menuPositionOptions: { type: Object, optional: true }, + menuCssClass: { type: [String, Array, Object], optional: true }, + selectOnBlur: { type: Boolean, optional: true }, + }; + static defaultProps = { + value: "", + placeholder: "", + title: "", + autocomplete: "new-password", + autoSelect: false, + dropdown: true, + onInput: () => {}, + onCancel: () => {}, + onChange: () => {}, + onBlur: () => {}, + onFocus: () => {}, + searchOnInputClick: true, + inputDebounceDelay: 250, + menuPositionOptions: {}, + menuCssClass: {}, + }; + + get timeout() { + return this.props.inputDebounceDelay; + } + + setup() { + this.nextSourceId = 0; + this.nextOptionId = 0; + this.sources = []; + this.inEdition = false; + this.mouseSelectionActive = false; + this.isOptionSelected = false; + + this.state = useState({ + navigationRev: 0, + optionsRev: 0, + open: false, + activeSourceOption: null, + value: this.props.value, + }); + + this.inputRef = useForwardRefToParent("input"); + this.listRef = useRef("sourcesList"); + if (this.props.autofocus) { + useAutofocus({ refName: "input" }); + } + this.root = useRef("root"); + + this.debouncedProcessInput = useDebounced(async () => { + const currentPromise = this.pendingPromise; + this.pendingPromise = null; + this.props.onInput({ + inputValue: this.inputRef.el.value, + }); + try { + await this.open(true); + currentPromise.resolve(); + } catch { + currentPromise.reject(); + } finally { + if (currentPromise === this.loadingPromise) { + this.loadingPromise = null; + } + } + }, this.timeout); + + useExternalListener(window, "scroll", this.externalClose, true); + useExternalListener(window, "pointerdown", this.externalClose, true); + useExternalListener(window, "mousemove", () => (this.mouseSelectionActive = true), true); + + this.hotkey = useService("hotkey"); + this.hotkeysToRemove = []; + + onWillUpdateProps((nextProps) => { + if (this.props.value !== nextProps.value || this.forceValFromProp) { + this.forceValFromProp = false; + if (!this.inEdition) { + this.state.value = nextProps.value; + this.inputRef.el.value = nextProps.value; + } + this.close(); + } + }); + + // position and size + if (this.props.dropdown) { + usePosition("sourcesList", () => this.targetDropdown, this.dropdownOptions); + } else { + this.open(false); + } + } + + get targetDropdown() { + return this.inputRef.el; + } + + get activeSourceOptionId() { + if (!this.isOpened || !this.state.activeSourceOption) { + return undefined; + } + const [sourceIndex, optionIndex] = this.state.activeSourceOption; + const source = this.sources[sourceIndex]; + return `${this.props.id || "autocomplete"}_${sourceIndex}_${ + source.isLoading ? "loading" : optionIndex + }`; + } + + get dropdownOptions() { + return { + position: "bottom-start", + ...this.props.menuPositionOptions, + }; + } + + get isOpened() { + return this.state.open; + } + + get hasOptions() { + for (const source of this.sources) { + if (source.isLoading || source.options.length) { + return true; + } + } + return false; + } + + get activeOption() { + if (!this.state.activeSourceOption) { + return null; + } + const [sourceIndex, optionIndex] = this.state.activeSourceOption; + return this.sources[sourceIndex].options[optionIndex]; + } + + open(useInput = false) { + this.state.open = true; + return this.loadSources(useInput); + } + + close() { + this.state.open = false; + this.state.activeSourceOption = null; + this.mouseSelectionActive = false; + } + + cancel() { + if (this.inputRef.el.value.length) { + if (this.props.autoSelect) { + this.inputRef.el.value = this.props.value; + this.props.onCancel(); + } + } + this.close(); + } + + async loadSources(useInput) { + this.sources = []; + this.state.activeSourceOption = null; + const proms = []; + for (const pSource of this.props.sources) { + const source = this.makeSource(pSource); + this.sources.push(source); + + const options = this.loadOptions( + pSource.options, + useInput ? this.inputRef.el.value.trim() : "" + ); + if (options instanceof Promise) { + source.isLoading = true; + const prom = options.then((options) => { + source.options = options.map((option) => this.makeOption(option)); + source.isLoading = false; + this.state.optionsRev++; + }); + proms.push(prom); + } else { + source.options = options.map((option) => this.makeOption(option)); + } + } + + await Promise.all(proms); + this.navigate(0); + this.scroll(); + } + get displayOptions() { + return !this.props.dropdown || (this.isOpened && this.hasOptions); + } + loadOptions(options, request) { + if (typeof options === "function") { + return options(request); + } else { + return options; + } + } + makeOption(option) { + return { + cssClass: "", + data: {}, + ...option, + id: ++this.nextOptionId, + unselectable: !option.onSelect, + }; + } + makeSource(source) { + return { + id: ++this.nextSourceId, + options: [], + isLoading: false, + placeholder: source.placeholder, + optionSlot: source.optionSlot, + }; + } + + isActiveSourceOption([sourceIndex, optionIndex]) { + return ( + this.state.activeSourceOption && + this.state.activeSourceOption[0] === sourceIndex && + this.state.activeSourceOption[1] === optionIndex + ); + } + + selectOption(option) { + this.inEdition = false; + if (option.unselectable) { + return; + } + + if (this.props.resetOnSelect) { + this.inputRef.el.value = ""; + } + this.isOptionSelected = true; + this.forceValFromProp = true; + option.onSelect(); + this.close(); + } + + navigate(direction) { + let step = Math.sign(direction); + if (!step) { + this.state.activeSourceOption = null; + step = 1; + } else { + this.state.navigationRev++; + } + + do { + if (this.state.activeSourceOption) { + let [sourceIndex, optionIndex] = this.state.activeSourceOption; + let source = this.sources[sourceIndex]; + + optionIndex += step; + if (0 > optionIndex || optionIndex >= source.options.length) { + sourceIndex += step; + source = this.sources[sourceIndex]; + + while (source && source.isLoading) { + sourceIndex += step; + source = this.sources[sourceIndex]; + } + + if (source) { + optionIndex = step < 0 ? source.options.length - 1 : 0; + } + } + + this.state.activeSourceOption = source ? [sourceIndex, optionIndex] : null; + } else { + let sourceIndex = step < 0 ? this.sources.length - 1 : 0; + let source = this.sources[sourceIndex]; + + while (source && source.isLoading) { + sourceIndex += step; + source = this.sources[sourceIndex]; + } + + if (source) { + const optionIndex = step < 0 ? source.options.length - 1 : 0; + if (optionIndex < source.options.length) { + this.state.activeSourceOption = [sourceIndex, optionIndex]; + } + } + } + } while (this.activeOption?.unselectable); + } + + onInputBlur() { + if (this.ignoreBlur) { + this.ignoreBlur = false; + return; + } + // If selectOnBlur is true, we select the first element + // of the autocomplete suggestions list, if this element exists + if (this.props.selectOnBlur && !this.isOptionSelected && this.sources[0]) { + const firstOption = this.sources[0].options[0]; + if (firstOption) { + this.state.activeSourceOption = firstOption.unselectable ? null : [0, 0]; + this.selectOption(this.activeOption); + } + } + this.props.onBlur({ + inputValue: this.inputRef.el.value, + }); + this.inEdition = false; + this.isOptionSelected = false; + } + onInputClick() { + if (!this.isOpened && this.props.searchOnInputClick) { + this.open(this.inputRef.el.value.trim() !== this.props.value.trim()); + } else { + this.close(); + } + } + onInputChange(ev) { + if (this.ignoreBlur) { + ev.stopImmediatePropagation(); + } + this.props.onChange({ + inputValue: this.inputRef.el.value, + isOptionSelected: this.ignoreBlur, + }); + } + async onInput() { + this.inEdition = true; + this.pendingPromise = this.pendingPromise || new Deferred(); + this.loadingPromise = this.pendingPromise; + this.debouncedProcessInput(); + } + + onInputFocus(ev) { + this.inputRef.el.setSelectionRange(0, this.inputRef.el.value.length); + this.props.onFocus(ev); + } + + get autoCompleteRootClass() { + let classList = ""; + if (this.props.class) { + classList += this.props.class; + } + if (this.props.dropdown) { + classList += " dropdown"; + } + return classList; + } + + get ulDropdownClass() { + return mergeClasses(this.props.menuCssClass, { + "dropdown-menu ui-autocomplete": this.props.dropdown, + "list-group": !this.props.dropdown, + }); + } + + async onInputKeydown(ev) { + const hotkey = getActiveHotkey(ev); + const isSelectKey = hotkey === "enter" || hotkey === "tab"; + + if (this.loadingPromise && isSelectKey) { + if (hotkey === "enter") { + ev.stopPropagation(); + ev.preventDefault(); + } + + await this.loadingPromise; + } + + switch (hotkey) { + case "enter": + if (!this.isOpened || !this.state.activeSourceOption) { + return; + } + this.selectOption(this.activeOption); + break; + case "escape": + if (!this.isOpened) { + return; + } + this.cancel(); + break; + case "tab": + case "shift+tab": + if (!this.isOpened) { + return; + } + if ( + this.props.autoSelect && + this.state.activeSourceOption && + (this.state.navigationRev > 0 || this.inputRef.el.value.length > 0) + ) { + this.selectOption(this.activeOption); + } + this.close(); + return; + case "arrowup": + this.navigate(-1); + if (!this.isOpened) { + this.open(true); + } + this.scroll(); + break; + case "arrowdown": + this.navigate(+1); + if (!this.isOpened) { + this.open(true); + } + this.scroll(); + break; + default: + return; + } + + ev.stopPropagation(); + ev.preventDefault(); + } + + onOptionMouseEnter(indices) { + if (!this.mouseSelectionActive) { + return; + } + + const [sourceIndex, optionIndex] = indices; + if (this.sources[sourceIndex].options[optionIndex]?.unselectable) { + this.state.activeSourceOption = null; + } else { + this.state.activeSourceOption = indices; + } + } + onOptionMouseLeave() { + this.state.activeSourceOption = null; + } + onOptionClick(option) { + this.selectOption(option); + this.inputRef.el.focus(); + } + onOptionPointerDown(option, ev) { + this.ignoreBlur = true; + if (option.unselectable) { + ev.preventDefault(); + } + } + + externalClose(ev) { + if (this.isOpened && !this.root.el.contains(ev.target)) { + this.cancel(); + } + } + + scroll() { + if (!this.activeSourceOptionId) { + return; + } + if (isScrollableY(this.listRef.el)) { + const element = this.listRef.el.querySelector(`#${this.activeSourceOptionId}`); + if (element) { + scrollTo(element); + } + } + } +} diff --git a/frontend/web/static/src/core/autocomplete/autocomplete.scss b/frontend/web/static/src/core/autocomplete/autocomplete.scss new file mode 100644 index 0000000..2dd7bbe --- /dev/null +++ b/frontend/web/static/src/core/autocomplete/autocomplete.scss @@ -0,0 +1,53 @@ + +.o-autocomplete { + .o-autocomplete--dropdown-menu { + // Needed because they are rendered at a lower stacking context compared to modals. + z-index: $zindex-modal + 1; + max-width: 600px; + } + .o-autocomplete--input { + width: 100%; + } + .o-autocomplete--mark { + padding: 0.1875em 0; + } + + .ui-menu-item { + > span { + --dropdown-link-hover-color: var(--dropdown-color); + --dropdown-link-hover-bg: var(--dropdown-bg); + } + + > a.ui-state-active { + margin: 0; + border: none; + font-weight: $font-weight-normal; + color: $dropdown-link-hover-color; + background-color: $dropdown-link-hover-bg; + } + + &.o_m2o_dropdown_option, &.o_m2o_start_typing, &.o_m2o_no_result { + text-indent: $o-dropdown-hpadding * .5; + } + + &.o_m2o_dropdown_option, &.o_calendar_dropdown_option { + > a { + color: $link-color; + &.ui-state-active:not(.o_m2o_start_typing) { + color: $link-hover-color; + } + } + } + + &.o_m2o_start_typing, &.o_m2o_no_result { + font-style: italic; + a.ui-menu-item-wrapper, a.ui-state-active, a.ui-state-active:hover { + background: none; + } + } + + &.o_m2o_start_typing > a.ui-state-active { + color: $dropdown-link-color; + } + } +} diff --git a/frontend/web/static/src/core/autocomplete/autocomplete.xml b/frontend/web/static/src/core/autocomplete/autocomplete.xml new file mode 100644 index 0000000..5d5a82b --- /dev/null +++ b/frontend/web/static/src/core/autocomplete/autocomplete.xml @@ -0,0 +1,88 @@ + + + + +
+ + + + +
+
+ +
diff --git a/frontend/web/static/src/core/avatar/avatar.scss b/frontend/web/static/src/core/avatar/avatar.scss new file mode 100644 index 0000000..df8b8b3 --- /dev/null +++ b/frontend/web/static/src/core/avatar/avatar.scss @@ -0,0 +1,13 @@ +// Avatar +.o_avatar img, +.o_avatar .o_avatar_empty, +img.o_avatar { + height: var(--Avatar-size, #{$o-avatar-size}); + aspect-ratio: 1; + object-fit: cover; +} + +.o_avatar_empty { + background: $o-black; + opacity: .1; +} \ No newline at end of file diff --git a/frontend/web/static/src/core/avatar/avatar.variables.scss b/frontend/web/static/src/core/avatar/avatar.variables.scss new file mode 100644 index 0000000..bed7155 --- /dev/null +++ b/frontend/web/static/src/core/avatar/avatar.variables.scss @@ -0,0 +1 @@ +$o-avatar-size: 1.7145em !default; diff --git a/frontend/web/static/src/core/badge/badge.scss b/frontend/web/static/src/core/badge/badge.scss new file mode 100644 index 0000000..adaf311 --- /dev/null +++ b/frontend/web/static/src/core/badge/badge.scss @@ -0,0 +1,8 @@ +.badge { + @for $size from 1 through length($o-colors) { + &.o_badge_color_#{$size - 1} { + background-color: adjust-color(nth($o-colors, $size), $lightness: 25%, $saturation: 15%) !important; + color: adjust-color(nth($o-colors, $size), $lightness: -40%, $saturation: -15%) !important; + } + } +} diff --git a/frontend/web/static/src/core/barcode/ZXingBarcodeDetector.js b/frontend/web/static/src/core/barcode/ZXingBarcodeDetector.js new file mode 100644 index 0000000..255d83d --- /dev/null +++ b/frontend/web/static/src/core/barcode/ZXingBarcodeDetector.js @@ -0,0 +1,153 @@ +/** + * Builder for BarcodeDetector-like polyfill class using ZXing library. + * + * @param {ZXing} ZXing Zxing library + * @returns {class} ZxingBarcodeDetector class + */ +export function buildZXingBarcodeDetector(ZXing) { + const ZXingFormats = new Map([ + ["aztec", ZXing.BarcodeFormat.AZTEC], + ["code_39", ZXing.BarcodeFormat.CODE_39], + ["code_128", ZXing.BarcodeFormat.CODE_128], + ["data_matrix", ZXing.BarcodeFormat.DATA_MATRIX], + ["ean_8", ZXing.BarcodeFormat.EAN_8], + ["ean_13", ZXing.BarcodeFormat.EAN_13], + ["itf", ZXing.BarcodeFormat.ITF], + ["pdf417", ZXing.BarcodeFormat.PDF_417], + ["qr_code", ZXing.BarcodeFormat.QR_CODE], + ["upc_a", ZXing.BarcodeFormat.UPC_A], + ["upc_e", ZXing.BarcodeFormat.UPC_E], + ]); + + const allSupportedFormats = Array.from(ZXingFormats.keys()); + + /** + * ZXingBarcodeDetector class + * + * BarcodeDetector-like polyfill class using ZXing library. + * API follows the Shape Detection Web API (specifically Barcode Detection). + */ + class ZXingBarcodeDetector { + /** + * @param {object} opts + * @param {Array} opts.formats list of codes' formats to detect + */ + constructor(opts = {}) { + const formats = opts.formats || allSupportedFormats; + const hints = new Map([ + [ + ZXing.DecodeHintType.POSSIBLE_FORMATS, + formats.map((format) => ZXingFormats.get(format)), + ], + // Enable Scanning at 90 degrees rotation + // https://github.com/zxing-js/library/issues/291 + [ZXing.DecodeHintType.TRY_HARDER, true], + ]); + this.reader = new ZXing.MultiFormatReader(); + this.reader.setHints(hints); + } + + /** + * Detect codes in image. + * + * @param {HTMLVideoElement} video source video element + * @returns {Promise} array of detected codes + */ + async detect(video) { + if (!(video instanceof HTMLVideoElement)) { + throw new DOMException( + "imageDataFrom() requires an HTMLVideoElement", + "InvalidArgumentError" + ); + } + if (!isVideoElementReady(video)) { + throw new DOMException("HTMLVideoElement is not ready", "InvalidStateError"); + } + const canvas = document.createElement("canvas"); + + let barcodeArea; + if (this.cropArea && (this.cropArea.x || this.cropArea.y)) { + barcodeArea = this.cropArea; + } else { + barcodeArea = { + x: 0, + y: 0, + width: video.videoWidth, + height: video.videoHeight, + }; + } + canvas.width = barcodeArea.width; + canvas.height = barcodeArea.height; + + const ctx = canvas.getContext("2d"); + + ctx.drawImage( + video, + barcodeArea.x, + barcodeArea.y, + barcodeArea.width, + barcodeArea.height, + 0, + 0, + barcodeArea.width, + barcodeArea.height + ); + + const luminanceSource = new ZXing.HTMLCanvasElementLuminanceSource(canvas); + const binaryBitmap = new ZXing.BinaryBitmap(new ZXing.HybridBinarizer(luminanceSource)); + try { + const result = this.reader.decodeWithState(binaryBitmap); + const { resultPoints } = result; + const boundingBox = DOMRectReadOnly.fromRect({ + x: resultPoints[0].x, + y: resultPoints[0].y, + height: Math.max(1, Math.abs(resultPoints[1].y - resultPoints[0].y)), + width: Math.max(1, Math.abs(resultPoints[1].x - resultPoints[0].x)), + }); + const cornerPoints = resultPoints; + const format = Array.from(ZXingFormats).find( + ([k, val]) => val === result.getBarcodeFormat() + ); + const rawValue = result.getText(); + return [ + { + boundingBox, + cornerPoints, + format, + rawValue, + }, + ]; + } catch (err) { + if (err.name === "NotFoundException") { + return []; + } + throw err; + } + } + + setCropArea(cropArea) { + this.cropArea = cropArea; + } + } + + /** + * Supported codes formats + * + * @static + * @returns {Promise} + */ + ZXingBarcodeDetector.getSupportedFormats = async () => allSupportedFormats; + + return ZXingBarcodeDetector; +} + +/** + * Check for HTMLVideoElement readiness. + * + * See https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState + */ +const HAVE_NOTHING = 0; +const HAVE_METADATA = 1; +export function isVideoElementReady(video) { + return ![HAVE_NOTHING, HAVE_METADATA].includes(video.readyState); +} diff --git a/frontend/web/static/src/core/barcode/barcode_dialog.js b/frontend/web/static/src/core/barcode/barcode_dialog.js new file mode 100644 index 0000000..7797e4d --- /dev/null +++ b/frontend/web/static/src/core/barcode/barcode_dialog.js @@ -0,0 +1,60 @@ +import { _t } from "@web/core/l10n/translation"; +import { Dialog } from "@web/core/dialog/dialog"; +import { Component, useState } from "@odoo/owl"; +import { BarcodeVideoScanner, isBarcodeScannerSupported } from "./barcode_video_scanner"; + +export class BarcodeDialog extends Component { + static template = "web.BarcodeDialog"; + static components = { + BarcodeVideoScanner, + Dialog, + }; + static props = ["facingMode", "close", "onResult", "onError"]; + + setup() { + this.state = useState({ + barcodeScannerSupported: isBarcodeScannerSupported(), + errorMessage: _t("Check your browser permissions"), + }); + } + + /** + * Detection success handler + * + * @param {string} result found code + */ + onResult(result) { + this.props.close(); + this.props.onResult(result); + } + + /** + * Detection error handler + * + * @param {Error} error + */ + onError(error) { + this.state.barcodeScannerSupported = false; + this.state.errorMessage = error.message; + } +} + +/** + * Opens the BarcodeScanning dialog and begins code detection using the device's camera. + * + * @returns {Promise} resolves when a {qr,bar}code has been detected + */ +export async function scanBarcode(env, facingMode = "environment") { + let res; + let rej; + const promise = new Promise((resolve, reject) => { + res = resolve; + rej = reject; + }); + env.services.dialog.add(BarcodeDialog, { + facingMode, + onResult: (result) => res(result), + onError: (error) => rej(error), + }); + return promise; +} diff --git a/frontend/web/static/src/core/barcode/barcode_dialog.scss b/frontend/web/static/src/core/barcode/barcode_dialog.scss new file mode 100644 index 0000000..ce759f6 --- /dev/null +++ b/frontend/web/static/src/core/barcode/barcode_dialog.scss @@ -0,0 +1,10 @@ +.modal .o-barcode-modal .modal-body { + overflow: hidden; + @include media-breakpoint-down(md) { + padding: 0; + } + + video { + object-fit: cover; + } +} diff --git a/frontend/web/static/src/core/barcode/barcode_dialog.xml b/frontend/web/static/src/core/barcode/barcode_dialog.xml new file mode 100644 index 0000000..d4046ad --- /dev/null +++ b/frontend/web/static/src/core/barcode/barcode_dialog.xml @@ -0,0 +1,15 @@ + + + + + +
+ + Unable to access camera + +
+
+
+
diff --git a/frontend/web/static/src/core/barcode/barcode_video_scanner.js b/frontend/web/static/src/core/barcode/barcode_video_scanner.js new file mode 100644 index 0000000..06de182 --- /dev/null +++ b/frontend/web/static/src/core/barcode/barcode_video_scanner.js @@ -0,0 +1,234 @@ +/* global BarcodeDetector */ + +import { browser } from "@web/core/browser/browser"; +import { delay } from "@web/core/utils/concurrency"; +import { loadJS } from "@web/core/assets"; +import { isVideoElementReady, buildZXingBarcodeDetector } from "./ZXingBarcodeDetector"; +import { CropOverlay } from "./crop_overlay"; +import { Component, onMounted, onWillStart, onWillUnmount, status, useRef, useState } from "@odoo/owl"; +import { _t } from "@web/core/l10n/translation"; +import { pick } from "@web/core/utils/objects"; + +export class BarcodeVideoScanner extends Component { + static template = "web.BarcodeVideoScanner"; + static components = { + CropOverlay, + }; + static props = { + cssClass: { type: String, optional: true }, + facingMode: { + type: String, + validate: (fm) => ["environment", "left", "right", "user"].includes(fm), + }, + close: { type: Function, optional: true }, + onReady: { type: Function, optional: true }, + onResult: Function, + onError: Function, + placeholder: { type: String, optional: true }, + delayBetweenScan: { type: Number, optional: true }, + }; + static defaultProps = { + cssClass: "w-100 h-100", + }; + /** + * @override + */ + setup() { + this.videoPreviewRef = useRef("videoPreview"); + this.detectorTimeout = null; + this.stream = null; + this.detector = null; + this.overlayInfo = {}; + this.zoomRatio = 1; + this.scanPaused = false; + this.state = useState({ + isReady: false, + }); + + onWillStart(async () => { + let DetectorClass; + // Use Barcode Detection API if available. + // As support is still bleeding edge (mainly Chrome on Android), + // also provides a fallback using ZXing library. + if ("BarcodeDetector" in window) { + DetectorClass = BarcodeDetector; + } else { + await loadJS("/web/static/lib/zxing-library/zxing-library.js"); + DetectorClass = buildZXingBarcodeDetector(window.ZXing); + } + const formats = await DetectorClass.getSupportedFormats(); + this.detector = new DetectorClass({ formats }); + }); + + onMounted(async () => { + const constraints = { + video: { facingMode: this.props.facingMode }, + audio: false, + }; + + try { + this.stream = await browser.navigator.mediaDevices.getUserMedia(constraints); + } catch (err) { + const errors = { + NotFoundError: _t("No device can be found."), + NotAllowedError: _t("Odoo needs your authorization first."), + }; + const errorMessage = _t("Could not start scanning. %(message)s", { + message: errors[err.name] || err.message, + }); + this.props.onError(new Error(errorMessage)); + return; + } + if (!this.videoPreviewRef.el) { + this.cleanStreamAndTimeout(); + const errorMessage = _t("Barcode Video Scanner could not be mounted properly."); + this.props.onError(new Error(errorMessage)); + return; + } + this.videoPreviewRef.el.srcObject = this.stream; + const ready = await this.isVideoReady(); + if (!ready) { + return; + } + const { height, width } = getComputedStyle(this.videoPreviewRef.el); + const divWidth = width.slice(0, -2); + const divHeight = height.slice(0, -2); + const tracks = this.stream.getVideoTracks(); + if (tracks.length) { + const [track] = tracks; + const settings = track.getSettings(); + this.zoomRatio = Math.min(divWidth / settings.width, divHeight / settings.height); + this.addZoomSlider(track, settings); + } + this.detectorTimeout = setTimeout(this.detectCode.bind(this), 100); + }); + + onWillUnmount(() => this.cleanStreamAndTimeout()); + } + + cleanStreamAndTimeout() { + clearTimeout(this.detectorTimeout); + this.detectorTimeout = null; + if (this.stream) { + this.stream.getTracks().forEach((track) => track.stop()); + this.stream = null; + } + } + + isZXingBarcodeDetector() { + return this.detector && this.detector.__proto__.constructor.name === "ZXingBarcodeDetector"; + } + + /** + * Check for camera preview element readiness + * + * @returns {Promise} resolves when the video element is ready + */ + async isVideoReady() { + // FIXME: even if it shouldn't happened, a timeout could be useful here. + while (!isVideoElementReady(this.videoPreviewRef.el)) { + await delay(10); + if (status(this) === "destroyed"){ + return false; + } + } + this.state.isReady = true; + if (this.props.onReady) { + this.props.onReady(); + } + return true; + } + + onResize(overlayInfo) { + this.overlayInfo = overlayInfo; + if (this.isZXingBarcodeDetector()) { + // TODO need refactoring when ZXing will support multiple result in one scan + // https://github.com/zxing-js/library/issues/346 + this.detector.setCropArea(this.adaptValuesWithRatio(this.overlayInfo, true)); + } + } + + /** + * Attempt to detect codes in the current camera preview's frame + */ + async detectCode() { + let barcodeDetected = false; + let codes = []; + try { + codes = await this.detector.detect(this.videoPreviewRef.el); + } catch (err) { + this.props.onError(err); + } + for (const code of codes) { + if ( + !this.isZXingBarcodeDetector() && + this.overlayInfo.x !== undefined && + this.overlayInfo.y !== undefined + ) { + const { x, y, width, height } = this.adaptValuesWithRatio(code.boundingBox); + if ( + x < this.overlayInfo.x || + x + width > this.overlayInfo.x + this.overlayInfo.width || + y < this.overlayInfo.y || + y + height > this.overlayInfo.y + this.overlayInfo.height + ) { + continue; + } + } + barcodeDetected = true; + this.barcodeDetected(code.rawValue); + break; + } + if (this.stream && (!barcodeDetected || !this.props.delayBetweenScan)) { + this.detectorTimeout = setTimeout(this.detectCode.bind(this), 100); + } + } + + barcodeDetected(barcode) { + if (this.props.delayBetweenScan && !this.scanPaused) { + this.scanPaused = true; + this.detectorTimeout = setTimeout(() => { + this.scanPaused = false; + this.detectorTimeout = setTimeout(this.detectCode.bind(this), 100); + }, this.props.delayBetweenScan); + } + this.props.onResult(barcode); + } + + adaptValuesWithRatio(domRect, dividerRatio = false) { + const newObject = pick(domRect, "x", "y", "width", "height"); + for (const key of Object.keys(newObject)) { + if (dividerRatio) { + newObject[key] /= this.zoomRatio; + } else { + newObject[key] *= this.zoomRatio; + } + } + return newObject; + } + + addZoomSlider(track, settings) { + const zoom = track.getCapabilities().zoom; + if (zoom?.min !== undefined && zoom?.max !== undefined) { + const inputElement = document.createElement("input"); + inputElement.type = "range"; + inputElement.min = zoom.min; + inputElement.max = zoom.max; + inputElement.step = zoom.step || 1; + inputElement.value = settings.zoom; + inputElement.classList.add("align-self-end", "m-5", "z-1"); + inputElement.addEventListener("input", async (event) => { + await track?.applyConstraints({ advanced: [{ zoom: inputElement.value }] }); + }); + this.videoPreviewRef.el.parentElement.appendChild(inputElement); + } + } +} + +/** + * Check for BarcodeScanner support + * @returns {boolean} + */ +export function isBarcodeScannerSupported() { + return Boolean(browser.navigator.mediaDevices && browser.navigator.mediaDevices.getUserMedia); +} diff --git a/frontend/web/static/src/core/barcode/barcode_video_scanner.xml b/frontend/web/static/src/core/barcode/barcode_video_scanner.xml new file mode 100644 index 0000000..11d0f8d --- /dev/null +++ b/frontend/web/static/src/core/barcode/barcode_video_scanner.xml @@ -0,0 +1,8 @@ + + + + + + + diff --git a/frontend/web/static/src/core/barcode/crop_overlay.js b/frontend/web/static/src/core/barcode/crop_overlay.js new file mode 100644 index 0000000..c76f143 --- /dev/null +++ b/frontend/web/static/src/core/barcode/crop_overlay.js @@ -0,0 +1,158 @@ +import { Component, useRef, onPatched } from "@odoo/owl"; +import { browser } from "@web/core/browser/browser"; +import { isIOS } from "@web/core/browser/feature_detection"; +import { clamp } from "@web/core/utils/numbers"; + +export class CropOverlay extends Component { + static template = "web.CropOverlay"; + static props = { + onResize: Function, + isReady: Boolean, + slots: { + type: Object, + shape: { + default: {}, + }, + }, + }; + + setup() { + this.localStorageKey = "o-barcode-scanner-overlay"; + this.cropContainerRef = useRef("crop-container"); + this.isMoving = false; + this.boundaryOverlay = {}; + this.relativePosition = { + x: 0, + y: 0, + }; + onPatched(() => { + this.setupCropRect(); + }); + this.isIOS = isIOS(); + } + + setupCropRect() { + if (!this.props.isReady) { + return; + } + this.computeDefaultPoint(); + this.computeOverlayPosition(); + this.calculateAndSetTransparentRect(); + this.executeOnResizeCallback(); + } + + boundPoint(pointValue, boundaryRect) { + return { + x: clamp(pointValue.x, boundaryRect.left, boundaryRect.left + boundaryRect.width), + y: clamp(pointValue.y, boundaryRect.top, boundaryRect.top + boundaryRect.height), + }; + } + + calculateAndSetTransparentRect() { + const cropTransparentRect = this.getTransparentRec( + this.relativePosition, + this.boundaryOverlay + ); + this.setCropValue(cropTransparentRect, this.relativePosition); + } + + computeOverlayPosition() { + const cropOverlayElement = this.cropContainerRef.el.querySelector(".o_crop_overlay"); + this.boundaryOverlay = cropOverlayElement.getBoundingClientRect(); + } + + executeOnResizeCallback() { + const transparentRec = this.getTransparentRec(this.relativePosition, this.boundaryOverlay); + browser.localStorage.setItem(this.localStorageKey, JSON.stringify(transparentRec)); + this.props.onResize({ + ...transparentRec, + width: this.boundaryOverlay.width - 2 * transparentRec.x, + height: this.boundaryOverlay.height - 2 * transparentRec.y, + }); + } + + computeDefaultPoint() { + const firstChildComputedStyle = getComputedStyle(this.cropContainerRef.el.firstChild); + const elementWidth = firstChildComputedStyle.width.slice(0, -2); + const elementHeight = firstChildComputedStyle.height.slice(0, -2); + + const stringSavedPoint = browser.localStorage.getItem(this.localStorageKey); + if (stringSavedPoint) { + const savedPoint = JSON.parse(stringSavedPoint); + this.relativePosition = { + x: clamp(savedPoint.x, 0, elementWidth), + y: clamp(savedPoint.y, 0, elementHeight), + }; + } else { + const stepWidth = elementWidth / 10; + const width = stepWidth * 8; + const height = width / 4; + const startY = elementHeight / 2 - height / 2; + this.relativePosition = { + x: stepWidth + width, + y: startY + height, + }; + } + } + getTransparentRec(point, rect) { + const middleX = rect.width / 2; + const middleY = rect.height / 2; + const newDeltaX = Math.abs(point.x - middleX); + const newDeltaY = Math.abs(point.y - middleY); + return { + x: middleX - newDeltaX, + y: middleY - newDeltaY, + }; + } + + setCropValue(point, iconPoint) { + if (!iconPoint) { + iconPoint = point; + } + this.cropContainerRef.el.style.setProperty("--o-crop-x", `${point.x}px`); + this.cropContainerRef.el.style.setProperty("--o-crop-y", `${point.y}px`); + this.cropContainerRef.el.style.setProperty("--o-crop-icon-x", `${iconPoint.x}px`); + this.cropContainerRef.el.style.setProperty("--o-crop-icon-y", `${iconPoint.y}px`); + } + + pointerDown(event) { + if (event.target.matches("input")) { + return; + } + event.preventDefault(); + if (event.target.matches(".o_crop_icon")) { + this.computeOverlayPosition(); + this.isMoving = true; + } + } + + pointerMove(event) { + if (!this.isMoving) { + return; + } + let eventPosition; + if (event.touches && event.touches.length) { + eventPosition = event.touches[0]; + } else { + eventPosition = event; + } + const { clientX, clientY } = eventPosition; + const restrictedPosition = this.boundPoint( + { + x: clientX, + y: clientY, + }, + this.boundaryOverlay + ); + this.relativePosition = { + x: restrictedPosition.x - this.boundaryOverlay.left, + y: restrictedPosition.y - this.boundaryOverlay.top, + }; + this.calculateAndSetTransparentRect(this.relativePosition); + } + + pointerUp(event) { + this.isMoving = false; + this.executeOnResizeCallback(); + } +} diff --git a/frontend/web/static/src/core/barcode/crop_overlay.scss b/frontend/web/static/src/core/barcode/crop_overlay.scss new file mode 100644 index 0000000..a6d83ef --- /dev/null +++ b/frontend/web/static/src/core/barcode/crop_overlay.scss @@ -0,0 +1,45 @@ +.o_crop_container { + position: relative; + + > * { + grid-row: 1 / -1; + grid-column: 1 / -1; + } + + .o_crop_overlay::after { + content: ''; + display: block; + } + + .o_crop_overlay:not(.o_crop_overlay_ios) { + background-color: RGB(0 0 0 / 0.75); + mix-blend-mode: darken; + + &::after { + height: 100%; + width: 100%; + clip-path: inset(var(--o-crop-y, 0px) var(--o-crop-x, 0px)); + background-color: white; + } + } + + .o_crop_overlay.o_crop_overlay_ios { + position: relative; + + &::after { + position: absolute; + inset: var(--o-crop-y, 0px) var(--o-crop-x, 0px); + border: 1px solid black; + } + } + + .o_crop_icon { + --o-crop-icon-width: 20px; + --o-crop-icon-height: 20px; + position: absolute; + width: var(--o-crop-icon-width); + height: var(--o-crop-icon-height); + left: calc(var(--o-crop-icon-x, 0px) - (var(--o-crop-icon-width) / 2)); + top: calc(var(--o-crop-icon-y, 0px) - (var(--o-crop-icon-height) / 2)); + } +} diff --git a/frontend/web/static/src/core/barcode/crop_overlay.xml b/frontend/web/static/src/core/barcode/crop_overlay.xml new file mode 100644 index 0000000..a3b3947 --- /dev/null +++ b/frontend/web/static/src/core/barcode/crop_overlay.xml @@ -0,0 +1,17 @@ + + + +
+ + +
+ + +
+
+ diff --git a/frontend/web/static/src/core/bottom_sheet/bottom_sheet.js b/frontend/web/static/src/core/bottom_sheet/bottom_sheet.js new file mode 100644 index 0000000..d9f34fe --- /dev/null +++ b/frontend/web/static/src/core/bottom_sheet/bottom_sheet.js @@ -0,0 +1,317 @@ +/** + * BottomSheet + * + * @class + */ +import { Component, useState, useRef, onMounted, useExternalListener } from "@odoo/owl"; +import { useHotkey } from "@web/core/hotkeys/hotkey_hook"; +import { useForwardRefToParent } from "@web/core/utils/hooks"; +import { useThrottleForAnimation } from "@web/core/utils/timing"; +import { compensateScrollbar } from "@web/core/utils/scrolling"; +import { getViewportDimensions, useViewportChange } from "@web/core/utils/dvu"; +import { clamp } from "@web/core/utils/numbers"; +import { browser } from "@web/core/browser/browser"; + +export class BottomSheet extends Component { + static template = "web.BottomSheet"; + + static defaultProps = { + class: "", + }; + + static props = { + // Main props + component: { type: Function }, + componentProps: { optional: true, type: Object }, + close: { type: Function }, + + class: { optional: true }, + role: { optional: true, type: String }, + + // Technical props + ref: { optional: true, type: Function }, + slots: { optional: true, type: Object }, + }; + + setup() { + this.maxHeightPercent = 90; + + this.state = useState({ + isPositionedReady: false, // Sheet is ready for display + isSnappingEnabled: false, + isDismissing: false, // Sheet is being dismissed + progress: 0, // Visual progress (0-1) + }); + + // Measurements and configuration + this.measurements = { + viewportHeight: 0, + naturalHeight: 0, + maxHeight: 0, + dismissThreshold: 0, + }; + + // Popover Ref Requirement + useForwardRefToParent("ref"); + + // References + this.containerRef = useRef("container"); + this.scrollRailRef = useRef("scrollRail"); + this.sheetRef = useRef("sheet"); + this.sheetBodyRef = useRef("ref"); + + // Create throttled version for onScroll + this.throttledOnScroll = useThrottleForAnimation(this.onScroll.bind(this)); + + // Adapt dimensions when mobile virtual-keyboards or browsers bars toggle + useViewportChange(() => { + if (this.state.isPositionedReady && !this.state.isDismissing) { + this.updateDimensions(); + } + }); + + // Handle "ESC" key press. + useHotkey("escape", () => this.slideOut()); + + // Handle mobile "back" gesture and "back" navigation button. + // Push a history state when the BottomSheet opens, intercept the browser's + // history events, prevents navigation by pushing another state and closes the sheet. + window.history.pushState({ bottomSheet: true }, ""); + this.handlePopState = () => { + if (this.state.isPositionedReady && !this.state.isDismissing) { + window.history.pushState({ bottomSheet: true }, ""); + this.slideOut(); + } + }; + useExternalListener(window, "popstate", this.handlePopState); + + onMounted(() => { + const isReduced = + browser.matchMedia(`(prefers-reduced-motion: reduce)`) === true || + browser.matchMedia(`(prefers-reduced-motion: reduce)`).matches === true; + + this.prefersReducedMotion = + isReduced || getComputedStyle(this.containerRef.el).animationName === "none"; + + this.initializeSheet(); + compensateScrollbar(this.scrollRailRef.el, true, true, "padding-right"); + }); + } + + /** + * Main initialization method for the sheet + * Sets up measurements, snap points, and event handlers + */ + initializeSheet() { + if (!this.containerRef.el || !this.scrollRailRef.el || !this.sheetRef.el) { + return; + } + + // Step 1: Take measurements + this.measureDimensions(); + + // Step 2: Apply Dimensions + this.applyDimensions(); + + // Step 3: Set initial position + this.positionSheet(); + + // Step 4: Setup event handlers after everything has been properly resized and positioned + this.setupEventHandlers(); + + // Step 5: Mark as ready + this.state.isPositionedReady = true; + + if (this.prefersReducedMotion) { + this.state.isSnappingEnabled = true; + } else { + this.sheetRef.el?.addEventListener( + "animationend", + () => (this.state.isSnappingEnabled = true), + { + once: true, + } + ); + this.sheetRef.el?.addEventListener( + "animationcancel", + () => (this.state.isSnappingEnabled = true), + { + once: true, + } + ); + } + } + + /** + * Updates dimensions when viewport changes + * Recalculates measurements and snap points while preserving extended state + */ + updateDimensions() { + // Temporarily disable snapping during update + this.state.isSnappingEnabled = false; + + // Update measurements with new viewport dimensions + this.measureDimensions(); + this.applyDimensions(); + + // // Update scroll position + const scrollTop = this.scrollRailRef.el.scrollTop; + + // Update progress value + this.updateProgressValue(scrollTop); + } + + /** + * Takes measurements of viewport and sheet dimensions + * Calculates natural height and other key measurements + */ + measureDimensions() { + const viewportHeight = getViewportDimensions().height; + + // Calculate heights based on percentages + const maxHeightPx = (this.maxHeightPercent / 100) * viewportHeight; + + // Reset any previously set constraints to measure natural height + const sheet = this.sheetRef.el; + sheet.style.removeProperty("min-height"); + sheet.style.removeProperty("height"); + + const naturalHeight = sheet.offsetHeight; + const initialHeightPx = Math.min(naturalHeight, maxHeightPx); + + // Store all measurements + this.measurements = { + viewportHeight, + naturalHeight, + initialHeight: initialHeightPx, + maxHeight: maxHeightPx, + dismissThreshold: Math.min(initialHeightPx * 0.3, 100), + }; + } + + /** + * Applies calculated dimensions to the DOM elements + * Sets CSS variables and styles based on measurements and snap points + */ + applyDimensions() { + const rail = this.scrollRailRef.el; + + // Convert heights to dvh percentages for CSS variables + const heightPercent = Math.min( + (this.measurements.initialHeight / this.measurements.viewportHeight) * 100, + this.maxHeightPercent + ); + + // Set CSS variables for heights + rail.style.setProperty("--sheet-height", `${heightPercent}dvh`); + rail.style.setProperty("--sheet-max-height", `${this.measurements.viewportHeight}px`); + rail.style.setProperty("--dismiss-height", `${this.measurements.initialHeight || 0}px`); + } + + /** + * Sets the initial position of the sheet + * Configures initial scroll position and overflow behavior + */ + positionSheet() { + const scrollRail = this.scrollRailRef.el; + const bodyContent = this.sheetBodyRef.el; + + const scrollValue = this.measurements.maxHeight; + + // Configure body content overflow + if (bodyContent) { + bodyContent.style.overflowY = "auto"; + } + + // Set scroll position + scrollRail.scrollTop = scrollValue || 0; + scrollRail.style.containerType = "scroll-state size"; + } + + /** + * Sets up event handlers for scroll and touch events + */ + setupEventHandlers() { + const scrollRail = this.scrollRailRef.el; + + // Add scroll event listener + scrollRail.addEventListener("scroll", this.throttledOnScroll); + } + + /** + * Handles scroll events on the rail element + * Updates progress, handles position snapping, and triggers dismissal + */ + onScroll() { + if (!this.scrollRailRef.el) { + return; + } + + const scrollTop = this.scrollRailRef.el.scrollTop; + + // Update progress value for visual effects + this.updateProgressValue(scrollTop); + + // Check for dismissal condition + if (scrollTop < this.measurements.dismissThreshold) { + this.slideOut(); + } + } + + /** + * Calculates and updates the progress value based on scroll position + * + * @param {number} scrollTop - Current scroll position + */ + updateProgressValue(scrollTop) { + const initialPosition = this.measurements.naturalHeight; + const progress = clamp(scrollTop / initialPosition, 0, 1); + + if (Math.abs(this.state.progress - progress) > 0.01) { + this.state.progress = progress; + } + } + + /** + * Initiates the slide out animation and dismissal + */ + slideOut() { + // Prevent duplicate calls + if (this.state.isDismissing) { + return; + } + + if (this.prefersReducedMotion) { + this.props.close?.(); + } else { + this.sheetRef.el?.addEventListener("animationend", () => this.props.close?.(), { + once: true, + }); + this.sheetRef.el?.addEventListener("animationcancel", () => this.props.close?.(), { + once: true, + }); + } + + // Update state to trigger animation + this.state.isDismissing = true; + this.state.isSnappingEnabled = false; + } + + /** + * Closes the sheet (public API) + */ + close() { + this.slideOut(); + } + + /** + * Handles back button press (public API) + */ + back() { + if (this.props.onBack) { + this.props.onBack(); + } else { + this.slideOut(); + } + } +} diff --git a/frontend/web/static/src/core/bottom_sheet/bottom_sheet.scss b/frontend/web/static/src/core/bottom_sheet/bottom_sheet.scss new file mode 100644 index 0000000..36da516 --- /dev/null +++ b/frontend/web/static/src/core/bottom_sheet/bottom_sheet.scss @@ -0,0 +1,343 @@ +.o_bottom_sheet { + // ============================================= + // Layout and inner elements + // ============================================= + --BottomSheet-slideIn-duration: #{$o_BottomSheet_slideIn_duration}; + --BottomSheet-slideIn-easing: #{$o_BottomSheet_slideIn_easing}; + --BottomSheet-slideOut-duration: #{$o_BottomSheet_slideOut_duration}; + --BottomSheet-slideOut-easing: #{$o_BottomSheet_slideOut_easing}; + + --BottomSheet-Sheet-borderColor: #{$o_BottomSheet_Sheet_borderColor}; + + @mixin has-more-content-visual { + content: ""; + position: fixed; + inset: auto 0 0; + height: map-get($spacers, 4); + background: linear-gradient(transparent, #00000050); + z-index: $zindex-offcanvas; + pointer-events: none; + } + + position: fixed; + top: 0; + left: 0; + right: 0; + height: 100dvh; + z-index: $zindex-offcanvas; + opacity: 0; + transform-style: preserve-3d; + contain: layout paint size; + + // Workaround + animation-name: has-animation; + @media (prefers-reduced-motion: reduce) { + animation-name: none; + } + + // Main scroll container for gesture handling + .o_bottom_sheet_rail { + @include o-position-absolute(0, 0, 0, 0); + overflow-y: auto; + scrollbar-width: none; + touch-action: pan-y; + pointer-events: auto; + + &::-webkit-scrollbar { + display: none; + } + + &.o_bottom_sheet_rail_prevent_overscroll, + &.o_bottom_sheet_rail_prevent_overscroll * { + overscroll-behavior: contain; + } + + &::after { + @include has-more-content-visual; + opacity: 0; + transition: opacity var(--BottomSheet-slideIn-duration, 500ms); + } + } + + // Set snapping behaviors + .o_bottom_sheet_dismiss, .o_bottom_sheet_spacer, .o_bottom_sheet_sheet { + scroll-snap-align: start; + scroll-snap-stop: always; + } + + // Backdrop overlay + .o_bottom_sheet_backdrop { + position: fixed; + inset: 0; + background-color: rgba($modal-backdrop-bg, $modal-backdrop-opacity); + opacity: 0; + transition: all 0.2s ease; + pointer-events: auto; + touch-action: none; + z-index: $zindex-offcanvas - 1; + backdrop-filter: blur(0px) grayscale(0%); + @media (prefers-reduced-motion: reduce) { + transition: none; + } + } + + // Dismiss area + .o_bottom_sheet_dismiss { + height: var(--dismiss-height, 50dvh); + } + + // Spacer area + .o_bottom_sheet_spacer { + height: calc(100dvh - var(--sheet-height, 50dvh)); + pointer-events: none; + } + + // The actual sheet + .o_bottom_sheet_sheet { + --offcanvas-box-shadow: #{$box-shadow}; + + margin: 0 auto; + min-height: var(--sheet-height); + max-height: var(--sheet-max-height); + border-radius: $border-radius-xl $border-radius-xl 0 0; + border-bottom-width: 0; + visibility: visible; + transition: none; + contain: content; + backface-visibility: hidden; + perspective: 1000px; + user-select: none; + background-color: $dropdown-bg; + + .o_bottom_sheet_body { + scrollbar-width: none; + flex: 1; + } + } + + // ============================================= + // States + // ============================================= + @keyframes bottom-sheet-in { + from { transform: translateY(100%) translateZ(0); } + to { transform: translateY(0) translateZ(0); } + } + + @keyframes bottom-sheet-out { + from { transform: translateY(0) translateZ(0); } + to { transform: translateY(100%) translateZ(0); } + } + + // BottomSheet is ready to be rendered on screen + &.o_bottom_sheet_ready { + opacity: 1; + + .o_bottom_sheet_sheet { + animation: var(--BottomSheet-slideIn-duration, 500ms) bottom-sheet-in var(--BottomSheet-slideIn-easing, ease-out) forwards; + @media (prefers-reduced-motion: reduce) { + animation: none; + } + } + + .o_bottom_sheet_backdrop { + opacity: MAX(var(--BottomSheet-progress, 0), 0.2); + backdrop-filter: blur(.5px) grayscale(50%); + } + } + + // User interactions are now allowed + &.o_bottom_sheet_snapping .o_bottom_sheet_rail { + // Enable snap behavior + scroll-snap-type: y mandatory; + + .o_bottom_sheet_backdrop { + transition: none; + } + + // Provide a visual safenet in case of elastic + // overscroll (mostly iOS). + &:before { + position: fixed; + inset: auto auto 0 50%; + height: calc(var(--sheet-height) - #{$border-radius-xl * 2}); + width: calc(100% - #{$border-width * 2}); + max-width: map-get($grid-breakpoints, sm) - ($border-width * 2); + background: $offcanvas-bg-color; + z-index: $zindex-offcanvas; + transform: translateY(calc((1 - var(--BottomSheet-progress)) * 150%)) translateX(-50%); + content: ""; + } + + &::after { + @container scroll-state(scrollable: bottom) { + opacity: 1; + } + } + } + + // Dismissing the sheet + &.o_bottom_sheet_dismissing { + .o_bottom_sheet_sheet { + animation: var(--BottomSheet-slideOut-duration, 300ms) bottom-sheet-out var(--BottomSheet-slideOut-easing, ease-in) forwards; + @media (prefers-reduced-motion: reduce) { + animation: none; + } + } + + .o_bottom_sheet_backdrop { + opacity: 0; + backdrop-filter: blur(0) grayscale(0%); + transition: all var(--BottomSheet-slideOut-duration, 300ms) var(--BottomSheet-slideOut-easing, ease-in); + @media (prefers-reduced-motion: reduce) { + transition: none; + } + } + } + + // When bottom sheet is open, apply styles to the body + @at-root .bottom-sheet-open { + overflow: hidden; + + // Scale down the main content + .o_navbar, .o_action_manager { + transition: transform $o_BottomSheet_slideIn_duration ease; + transform: scale(.95) translateZ(0); + transform-origin: center top; + @media (prefers-reduced-motion: reduce) { + transition: none; + } + } + + // Avoid blank on the side + &:not(.o_home_menu_background) .o_main_navbar { + box-shadow: 20px 0 0 $o-navbar-background, -20px 0 0 $o-navbar-background; + } + + &:not(.bottom-sheet-open-multiple):has(.o_bottom_sheet_dismissing) { + .o_navbar, .o_action_manager { + transition: transform $o_BottomSheet_slideOut_duration ease; + transform: scale(1) translateZ(0); + @media (prefers-reduced-motion: reduce) { + transition: none; + } + } + } + } +} + +// ============================================= +// Inner components design +// ============================================= +.o_bottom_sheet .o_bottom_sheet_sheet { + --BottomSheet-Entry-paddingX: #{$list-group-item-padding-x}; + + %BottomSheet-Entry-active { + position: relative; + border: $border-width solid $list-group-active-border-color; + border-radius: $border-radius-lg; + color: var(--BottomSheetStatusBar__entry-color--active, #{color-contrast($component-active-bg)}); + + &:not(.focus) { + background: var(--BottomSheetStatusBar__entry-background--active, #{rgba($component-active-bg, .5)}); + } + + &::before { + content: none !important; + } + + &::after { + @include o-position-absolute(50%, $list-group-item-padding-x); + transform: translateY(-50%); + color: $o-action; + // .fa + text-rendering: auto; + font: normal normal normal 14px/1 FontAwesome; + // .fa-check + content: ""; + } + } + + // TreeEntry + --treeEntry-padding-v: 1.4rem; + + // Dropdown + .dropdown-divider { + --dropdown-divider-bg: #{$border-color}; + margin: map-get($spacers, 2) ($offcanvas-padding-x * .5); + } + + .dropdown-item, .dropdown-header { + --dropdown-item-padding-y: #{map-get($spacers, 3)}; + --dropdown-item-padding-x: var(--BottomSheet-Entry-paddingX); + --dropdown-header-padding-y: var(--dropdown-item-padding-y); + --dropdown-header-padding-x: var(--dropdown-item-padding-x); + + font-size: $h5-font-size; + font-weight: $o-font-weight-medium; + text-align: start !important; + } + + .dropdown-item .o_stat_value { + display: flex; + } + + .o_bottom_sheet_body:not(.o_custom_bottom_sheet) { + // Dropdown + .dropdown-item { + &.active, &.selected { + @extend %BottomSheet-Entry-active; + } + } + } + + .o_accordion_toggle { + &::after { + // Reset original style + border: unset; + transform: unset; + + @include o-position-absolute(var(--dropdown-item-padding-y), $list-group-item-padding-x); + padding-block: map-get($spacers, 2); + // .fa + text-rendering: auto; + font: normal normal normal 14px/1 FontAwesome; + // .fa-caret-down + content: "\f0d7"; + } + &.open::after { + // .fa-caret-up + content: "\f0d8"; + } + } + + .o_kanban_card_manage_settings:has(.o_colorlist) { + &, > div:last-child { + padding: 0; + } + } + .row.o_kanban_card_manage_settings:last-child { + &:has(:not(.o_field_boolean_favorite)) { + flex-direction: column-reverse; + + .o_field_kanban_color_picker { + padding: map-get($spacers, 3); + } + } + + div[class*="col-"] + div[class*="col-"] { + border-left: none; + } + + > div[class*="col-"] { + width: 100%; + padding: 0; + + a { + margin: 0; + padding: #{map-get($spacers, 3)} var(--BottomSheet-Entry-paddingX); + font-size: $h5-font-size; + font-weight: $o-font-weight-medium; + } + } + } +} diff --git a/frontend/web/static/src/core/bottom_sheet/bottom_sheet.variables.scss b/frontend/web/static/src/core/bottom_sheet/bottom_sheet.variables.scss new file mode 100644 index 0000000..4177e24 --- /dev/null +++ b/frontend/web/static/src/core/bottom_sheet/bottom_sheet.variables.scss @@ -0,0 +1,8 @@ + +$o_BottomSheet_Sheet_borderColor: transparent !default; + +$o_BottomSheet_slideIn_duration: 400ms !default; +$o_BottomSheet_slideIn_easing: $o-easing-enter !default; + +$o_BottomSheet_slideOut_duration: 200ms !default; +$o_BottomSheet_slideOut_easing: $o-easing-exit !default; diff --git a/frontend/web/static/src/core/bottom_sheet/bottom_sheet.xml b/frontend/web/static/src/core/bottom_sheet/bottom_sheet.xml new file mode 100644 index 0000000..75cde29 --- /dev/null +++ b/frontend/web/static/src/core/bottom_sheet/bottom_sheet.xml @@ -0,0 +1,70 @@ + + + +
+ +
+ +
+ + +
+ + +
+ + + +
+ + diff --git a/frontend/web/static/src/core/bottom_sheet/bottom_sheet_service.js b/frontend/web/static/src/core/bottom_sheet/bottom_sheet_service.js new file mode 100644 index 0000000..f472a20 --- /dev/null +++ b/frontend/web/static/src/core/bottom_sheet/bottom_sheet_service.js @@ -0,0 +1,71 @@ +import { markRaw } from "@odoo/owl"; +import { BottomSheet } from "@web/core/bottom_sheet/bottom_sheet"; +import { registry } from "@web/core/registry"; + +/** + * @typedef {{ + * env?: object; + * onClose?: () => void; + * class?: string; + * role?: string; + * ref?: Function; + * useBottomSheet?: Boolean; + * }} PopoverServiceAddOptions + * + * @typedef {ReturnType["add"]} PopoverServiceAddFunction + */ + +export const popoverService = { + dependencies: ["overlay"], + start(_, { overlay }) { + let bottomSheetCount = 0; + /** + * Signals the manager to add a popover. + * + * @param {HTMLElement} target + * @param {typeof import("@odoo/owl").Component} component + * @param {object} [props] + * @param {PopoverServiceAddOptions} [options] + * @returns {() => void} + */ + const add = (target, component, props = {}, options = {}) => { + function removeAndUpdateCount() { + _remove(); + bottomSheetCount--; + if (bottomSheetCount === 0) { + document.body.classList.remove("bottom-sheet-open"); + } else if (bottomSheetCount === 1) { + document.body.classList.remove("bottom-sheet-open-multiple"); + } + } + const _remove = overlay.add( + BottomSheet, + { + close: removeAndUpdateCount, + component, + componentProps: markRaw(props), + ref: options.ref, + class: options.class, + role: options.role, + }, + { + env: options.env, + onRemove: options.onClose, + rootId: target.getRootNode()?.host?.id, + } + ); + bottomSheetCount++; + if (bottomSheetCount === 1) { + document.body.classList.add("bottom-sheet-open"); + } else if (bottomSheetCount > 1) { + document.body.classList.add("bottom-sheet-open-multiple"); + } + + return removeAndUpdateCount; + }; + + return { add }; + }, +}; + +registry.category("services").add("bottom_sheet", popoverService); diff --git a/frontend/web/static/src/core/browser/browser.js b/frontend/web/static/src/core/browser/browser.js new file mode 100644 index 0000000..0726129 --- /dev/null +++ b/frontend/web/static/src/core/browser/browser.js @@ -0,0 +1,112 @@ +/** + * Browser + * + * This file exports an object containing common browser API. It may not look + * incredibly useful, but it is very convenient when one needs to test code using + * these methods. With this indirection, it is possible to patch the browser + * object for a test. + */ + +let sessionStorage; +let localStorage; +try { + sessionStorage = window.sessionStorage; + localStorage = window.localStorage; + // Safari crashes in Private Browsing + localStorage.setItem("__localStorage__", "true"); + localStorage.removeItem("__localStorage__"); +} catch { + localStorage = makeRAMLocalStorage(); + sessionStorage = makeRAMLocalStorage(); +} + +export const browser = { + addEventListener: window.addEventListener.bind(window), + dispatchEvent: window.dispatchEvent.bind(window), + AnalyserNode: window.AnalyserNode, + Audio: window.Audio, + AudioBufferSourceNode: window.AudioBufferSourceNode, + AudioContext: window.AudioContext, + AudioWorkletNode: window.AudioWorkletNode, + BeforeInstallPromptEvent: window.BeforeInstallPromptEvent?.bind(window), + GainNode: window.GainNode, + MediaStreamAudioSourceNode: window.MediaStreamAudioSourceNode, + removeEventListener: window.removeEventListener.bind(window), + setTimeout: window.setTimeout.bind(window), + clearTimeout: window.clearTimeout.bind(window), + setInterval: window.setInterval.bind(window), + clearInterval: window.clearInterval.bind(window), + performance: window.performance, + requestAnimationFrame: window.requestAnimationFrame.bind(window), + cancelAnimationFrame: window.cancelAnimationFrame.bind(window), + console: window.console, + history: window.history, + matchMedia: window.matchMedia.bind(window), + navigator, + Notification: window.Notification, + open: window.open.bind(window), + SharedWorker: window.SharedWorker, + Worker: window.Worker, + XMLHttpRequest: window.XMLHttpRequest, + localStorage, + sessionStorage, + fetch: window.fetch.bind(window), + innerHeight: window.innerHeight, + innerWidth: window.innerWidth, + ontouchstart: window.ontouchstart, + BroadcastChannel: window.BroadcastChannel, + visualViewport: window.visualViewport, +}; + +Object.defineProperty(browser, "location", { + set(val) { + window.location = val; + }, + get() { + return window.location; + }, + configurable: true, +}); + +Object.defineProperty(browser, "innerHeight", { + get: () => window.innerHeight, + configurable: true, +}); +Object.defineProperty(browser, "innerWidth", { + get: () => window.innerWidth, + configurable: true, +}); + +// ----------------------------------------------------------------------------- +// memory localStorage +// ----------------------------------------------------------------------------- + +/** + * @returns {typeof window["localStorage"]} + */ +export function makeRAMLocalStorage() { + let store = {}; + return { + setItem(key, value) { + const newValue = String(value); + store[key] = newValue; + window.dispatchEvent(new StorageEvent("storage", { key, newValue })); + }, + getItem(key) { + return store[key] ?? null; + }, + clear() { + store = {}; + }, + removeItem(key) { + delete store[key]; + window.dispatchEvent(new StorageEvent("storage", { key, newValue: null })); + }, + get length() { + return Object.keys(store).length; + }, + key() { + return ""; + }, + }; +} diff --git a/frontend/web/static/src/core/browser/cookie.js b/frontend/web/static/src/core/browser/cookie.js new file mode 100644 index 0000000..c9866a6 --- /dev/null +++ b/frontend/web/static/src/core/browser/cookie.js @@ -0,0 +1,37 @@ +/** + * Utils to make use of document.cookie + * https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies + * As recommended, storage should not be done by the cookie + * but with localStorage/sessionStorage + */ + +const COOKIE_TTL = 24 * 60 * 60 * 365; + +export const cookie = { + get _cookieMonster() { + return document.cookie; + }, + set _cookieMonster(value) { + document.cookie = value; + }, + get(str) { + const parts = this._cookieMonster.split("; "); + for (const part of parts) { + const [key, value] = part.split(/=(.*)/); + if (key === str) { + return value || ""; + } + } + }, + set(key, value, ttl = COOKIE_TTL) { + let fullCookie = []; + if (value !== undefined) { + fullCookie.push(`${key}=${value}`); + } + fullCookie = fullCookie.concat(["path=/", `max-age=${Math.floor(ttl)}`]); + this._cookieMonster = fullCookie.join("; "); + }, + delete(key) { + this.set(key, "kill", 0); + }, +}; diff --git a/frontend/web/static/src/core/browser/feature_detection.js b/frontend/web/static/src/core/browser/feature_detection.js new file mode 100644 index 0000000..59f7846 --- /dev/null +++ b/frontend/web/static/src/core/browser/feature_detection.js @@ -0,0 +1,83 @@ +import { browser } from "./browser"; + +// ----------------------------------------------------------------------------- +// Feature detection +// ----------------------------------------------------------------------------- + +/** + * True if the browser is based on Chromium (Google Chrome, Opera, Edge). + */ +export function isBrowserChrome() { + return /Chrome/i.test(browser.navigator.userAgent); +} + +export function isBrowserFirefox() { + return /Firefox/i.test(browser.navigator.userAgent); +} + +/** + * True if the browser is Microsoft Edge. + */ +export function isBrowserMicrosoftEdge() { + return /Edg/i.test(browser.navigator.userAgent); +} + +/** + * true if the browser is based on Safari (Safari, Epiphany) + * + * @returns {boolean} + */ +export function isBrowserSafari() { + return !isBrowserChrome() && browser.navigator.userAgent?.includes("Safari"); +} + +export function isAndroid() { + return /Android/i.test(browser.navigator.userAgent); +} + +export function isIOS() { + let isIOSPlatform = false; + if ("platform" in browser.navigator) { + isIOSPlatform = browser.navigator.platform === "MacIntel"; + } + return ( + /(iPad|iPhone|iPod)/i.test(browser.navigator.userAgent) || + (isIOSPlatform && maxTouchPoints() > 1) + ); +} + +export function isOtherMobileOS() { + return /(webOS|BlackBerry|Windows Phone)/i.test(browser.navigator.userAgent); +} + +export function isMacOS() { + return /Mac/i.test(browser.navigator.userAgent); +} + +export function isMobileOS() { + return isAndroid() || isIOS() || isOtherMobileOS(); +} + +export function isIosApp() { + return /OdooMobile \(iOS\)/i.test(browser.navigator.userAgent); +} + +export function isAndroidApp() { + return /OdooMobile.+Android/i.test(browser.navigator.userAgent); +} + +export function isDisplayStandalone() { + return browser.matchMedia("(display-mode: standalone)").matches; +} + +export function hasTouch() { + return browser.ontouchstart !== undefined || browser.matchMedia("(pointer:coarse)").matches; +} + +export function maxTouchPoints() { + return browser.navigator.maxTouchPoints || 1; +} + +export function isVirtualKeyboardSupported() { + return "virtualKeyboard" in browser.navigator; +} diff --git a/frontend/web/static/src/core/browser/router.js b/frontend/web/static/src/core/browser/router.js new file mode 100644 index 0000000..9a19b7c --- /dev/null +++ b/frontend/web/static/src/core/browser/router.js @@ -0,0 +1,423 @@ +import { EventBus } from "@odoo/owl"; +import { omit, pick } from "../utils/objects"; +import { compareUrls, objectToUrlEncodedString } from "../utils/urls"; +import { browser } from "./browser"; +import { isDisplayStandalone } from "@web/core/browser/feature_detection"; +import { slidingWindow } from "@web/core/utils/arrays"; +import { isNumeric } from "@web/core/utils/strings"; + +// Keys that are serialized in the URL as path segments instead of query string +export const PATH_KEYS = ["resId", "action", "active_id", "model"]; + +export const routerBus = new EventBus(); + +function isScopedApp() { + return browser.location.href.includes("/scoped_app") && isDisplayStandalone(); +} + +/** + * Casts the given string to a number if possible. + * + * @param {string} value + * @returns {string|number} + */ +function cast(value) { + return !value || isNaN(value) ? value : Number(value); +} + +/** + * @typedef {{ [key: string]: string }} Query + * @typedef {{ [key: string]: any }} Route + */ + +function parseString(str) { + const parts = str.split("&"); + const result = {}; + for (const part of parts) { + const [key, value] = part.split("="); + const decoded = decodeURIComponent(value || ""); + result[key] = cast(decoded); + } + return result; +} +/** + * @param {object} values An object with the values of the new state + * @param {boolean} replace whether the values should replace the state or be + * layered on top of the current state + * @returns {object} the next state of the router + */ +function computeNextState(values, replace) { + const nextState = replace ? pick(state, ..._lockedKeys) : { ...state }; + Object.assign(nextState, values); + // Update last entry in the actionStack + if (nextState.actionStack?.length) { + Object.assign(nextState.actionStack.at(-1), pick(nextState, ...PATH_KEYS)); + } + return sanitizeSearch(nextState); +} + +function sanitize(obj, valueToRemove) { + return Object.fromEntries( + Object.entries(obj) + .filter(([, v]) => v !== valueToRemove) + .map(([k, v]) => [k, cast(v)]) + ); +} + +function sanitizeSearch(search) { + return sanitize(search); +} + +function sanitizeHash(hash) { + return sanitize(hash, ""); +} + +/** + * @param {string} hash + * @returns {any} + */ +export function parseHash(hash) { + return hash && hash !== "#" ? parseString(hash.slice(1)) : {}; +} + +/** + * @param {string} search + * @returns {any} + */ +export function parseSearchQuery(search) { + return search ? parseString(search.slice(1)) : {}; +} + +function pathFromActionState(state) { + const path = []; + const { action, model, active_id, resId } = state; + if (active_id && typeof active_id === "number") { + path.push(active_id); + } + if (action) { + if (typeof action === "number" || action.includes(".")) { + path.push(`action-${action}`); + } else { + path.push(action); + } + } else if (model) { + if (model.includes(".")) { + path.push(model); + } else { + // A few models don't have a dot at all, we need to distinguish + // them from action paths (eg: website) + path.push(`m-${model}`); + } + } + if (resId && (typeof resId === "number" || resId === "new")) { + path.push(resId); + } + return path.join("/"); +} + +export function startUrl() { + return isScopedApp() ? "scoped_app" : "odoo"; +} + +/** + * @param {{ [key: string]: any }} state + * @returns + */ +function stateToUrl(state) { + let path = ""; + const pathKeysToOmit = [..._hiddenKeysFromUrl]; + const actionStack = (state.actionStack || [state]).map((a) => ({ ...a })); + if (actionStack.at(-1)?.action !== "menu") { + for (const [prevAct, currentAct] of slidingWindow(actionStack, 2).reverse()) { + const { action: prevAction, resId: prevResId, active_id: prevActiveId } = prevAct; + const { action: currentAction, active_id: currentActiveId } = currentAct; + // actions would typically map to a path like `active_id/action/res_id` + if (currentActiveId === prevResId) { + // avoid doubling up when the active_id is the same as the previous action's res_id + delete currentAct.active_id; + } + if (prevAction === currentAction && !prevResId && currentActiveId === prevActiveId) { + //avoid doubling up the action and the active_id when a single-record action is preceded by a multi-record action + delete currentAct.action; + delete currentAct.active_id; + } + } + const pathSegments = actionStack.map(pathFromActionState).filter(Boolean); + if (pathSegments.length) { + path = `/${pathSegments.join("/")}`; + } + } + if (state.active_id && typeof state.active_id !== "number") { + pathKeysToOmit.splice(pathKeysToOmit.indexOf("active_id"), 1); + } + if (state.resId && typeof state.resId !== "number" && state.resId !== "new") { + pathKeysToOmit.splice(pathKeysToOmit.indexOf("resId"), 1); + } + const search = objectToUrlEncodedString(omit(state, ...pathKeysToOmit)); + const start_url = startUrl(); + return `/${start_url}${path}${search ? `?${search}` : ""}`; +} + +function urlToState(urlObj) { + const { pathname, hash, search } = urlObj; + const state = parseSearchQuery(search); + + // ** url-retrocompatibility ** + // If the url contains a hash, it can be for two motives: + // 1. It is an anchor link, in that case, we ignore it, as it will not have a keys/values format + // the sanitizeHash function will remove it from the hash object. + // 2. It has one or more keys/values, in that case, we merge it with the search. + if (pathname === "/web") { + const sanitizedHash = sanitizeHash(parseHash(hash)); + // Old urls used "id", it is now resId for clarity. Remap to the new name. + if (sanitizedHash.id) { + sanitizedHash.resId = sanitizedHash.id; + delete sanitizedHash.id; + delete sanitizedHash.view_type; + } else if (sanitizedHash.view_type === "form") { + sanitizedHash.resId = "new"; + delete sanitizedHash.view_type; + } + Object.assign(state, sanitizedHash); + const url = browser.location.origin + router.stateToUrl(state); + urlObj.href = url; + } + + const [prefix, ...splitPath] = urlObj.pathname.split("/").filter(Boolean); + + if (["odoo", "scoped_app"].includes(prefix)) { + const actionParts = [...splitPath.entries()].filter( + ([_, part]) => !isNumeric(part) && part !== "new" + ); + const actions = []; + for (const [i, part] of actionParts) { + const action = {}; + const [left, right] = [splitPath[i - 1], splitPath[i + 1]]; + if (isNumeric(left)) { + action.active_id = parseInt(left); + } + + if (right === "new") { + action.resId = "new"; + } else if (isNumeric(right)) { + action.resId = parseInt(right); + } + + if (part.startsWith("action-")) { + // numeric id or xml_id + const actionId = part.slice(7); + action.action = isNumeric(actionId) ? parseInt(actionId) : actionId; + } else if (part.startsWith("m-")) { + action.model = part.slice(2); + } else if (part.includes(".")) { + action.model = part; + } else { + // action tag or path + action.action = part; + } + + if (action.resId && action.action) { + actions.push(omit(action, "resId")); + } + // Don't create actions for models without resId unless they're the last one. + // If the last one is a model but doesn't have a view_type, the action service will not mount it anyway. + if (action.action || action.resId || i === splitPath.length - 1) { + actions.push(action); + } + } + const activeAction = actions.at(-1); + if (activeAction) { + Object.assign(state, activeAction); + state.actionStack = actions; + } + if (prefix === "scoped_app" && !isDisplayStandalone()) { + // make sure /scoped_app are redirected to /odoo when using the browser instead of the PWA + const url = browser.location.origin + router.stateToUrl(state); + urlObj.href = url; + } + } + return state; +} + +let state; +let pushTimeout; +let pushArgs; +let _lockedKeys; +let _hiddenKeysFromUrl = new Set(); + +export function startRouter() { + const url = new URL(browser.location); + state = router.urlToState(url); + // ** url-retrocompatibility ** + if (browser.location.pathname === "/web") { + // Change the url of the current history entry to the canonical url. + // This change should be done only at the first load, and not when clicking on old style internal urls. + // Or when clicking back/forward on the browser. + browser.history.replaceState(browser.history.state, null, url.href); + } + pushTimeout = null; + pushArgs = { + replace: false, + reload: false, + state: {}, + }; + _lockedKeys = new Set(["debug", "lang"]); + _hiddenKeysFromUrl = new Set([...PATH_KEYS, "actionStack"]); +} + +/** + * When the user navigates history using the back/forward button, the browser + * dispatches a popstate event with the state that was in the history for the + * corresponding history entry. We just adopt that state so that the webclient + * can use that previous state without forcing a full page reload. + */ +browser.addEventListener("popstate", (ev) => { + browser.clearTimeout(pushTimeout); + if (!ev.state) { + // We are coming from a click on an anchor. + // Add the current state to the history entry so that a future loadstate behaves as expected. + browser.history.replaceState({ nextState: state }, "", browser.location.href); + return; + } + state = ev.state?.nextState || router.urlToState(new URL(browser.location)); + // Some client actions want to handle loading their own state. This is a ugly hack to allow not + // reloading the webclient's state when they manipulate history. + if (!ev.state?.skipRouteChange && !router.skipLoad) { + routerBus.trigger("ROUTE_CHANGE"); + } + router.skipLoad = false; +}); + +/** + * When the user navigates the history using the back/forward button, some browsers (Safari iOS and + * Safari MacOS) can restore the page using the `bfcache` (especially when we come back from an + * external website). Unfortunately, Odoo wasn't designed to be compatible with this cache, which + * leads to inconsistencies. When the `bfcache` is used to restore a page, we reload the current + * page, to be sure that all the elements have been rendered correctly. + */ +browser.addEventListener("pageshow", (ev) => { + if (ev.persisted) { + browser.clearTimeout(pushTimeout); + routerBus.trigger("ROUTE_CHANGE"); + } +}); + +/** + * When clicking internal links, do a loadState instead of a full page reload. + * This also alows the mobile app to not open an in-app browser for them. + */ +browser.addEventListener("click", (ev) => { + if (ev.defaultPrevented || ev.target.closest("[contenteditable]")) { + return; + } + const a = ev.target.closest("a"); + const href = a?.getAttribute("href"); + if (href && !href.startsWith("#")) { + let url; + try { + // ev.target.href is the full url including current path + url = new URL(a.href); + } catch { + return; + } + if ( + browser.location.host === url.host && + browser.location.pathname.startsWith("/odoo") && + (["/web", "/odoo"].includes(url.pathname) || url.pathname.startsWith("/odoo/")) && + a.target !== "_blank" + ) { + ev.preventDefault(); + state = router.urlToState(url); + if (url.pathname.startsWith("/odoo") && url.hash) { + browser.history.pushState({}, "", url.href); + } + new Promise((res) => setTimeout(res, 0)).then(() => routerBus.trigger("ROUTE_CHANGE")); + } + } +}); + +/** + * @param {string} mode + */ +function makeDebouncedPush(mode) { + function doPush() { + // Calculates new route based on aggregated search and options + const nextState = computeNextState(pushArgs.state, pushArgs.replace); + const url = browser.location.origin + router.stateToUrl(nextState); + if (!compareUrls(url + browser.location.hash, browser.location.href)) { + // If the route changed: pushes or replaces browser state + if (mode === "push") { + // Because doPush is delayed, the history entry will have the wrong name. + // We set the document title to what it was at the time of the pushState + // call, then push, which generates the history entry with the right title + // then restore the title to what it's supposed to be + const originalTitle = document.title; + document.title = pushArgs.title; + browser.history.pushState({ nextState }, "", url); + document.title = originalTitle; + } else { + browser.history.replaceState({ nextState }, "", url); + } + } else { + // URL didn't change but state might have, update it in place + browser.history.replaceState({ nextState }, "", browser.location.href); + } + state = nextState; + if (pushArgs.reload) { + browser.location.reload(); + } + } + /** + * @param {object} state + * @param {object} options + */ + return function pushOrReplaceState(state, options = {}) { + pushArgs.replace ||= options.replace; + pushArgs.reload ||= options.reload; + pushArgs.title = document.title; + Object.assign(pushArgs.state, state); + browser.clearTimeout(pushTimeout); + const push = () => { + doPush(); + pushTimeout = null; + pushArgs = { + replace: false, + reload: false, + state: {}, + }; + }; + if (options.sync) { + push(); + } else { + pushTimeout = browser.setTimeout(() => { + push(); + }); + } + }; +} + +export const router = { + get current() { + return state; + }, + // state <-> url conversions can be patched if needed in a custom webclient. + stateToUrl, + urlToState, + // TODO: stop debouncing these and remove the ugly hack to have the correct title for history entries + pushState: makeDebouncedPush("push"), + replaceState: makeDebouncedPush("replace"), + cancelPushes: () => browser.clearTimeout(pushTimeout), + addLockedKey: (key) => _lockedKeys.add(key), + hideKeyFromUrl: (key) => _hiddenKeysFromUrl.add(key), + skipLoad: false, +}; + +startRouter(); + +export function objectToQuery(obj) { + const query = {}; + Object.entries(obj).forEach(([k, v]) => { + query[k] = v ? String(v) : v; + }); + return query; +} diff --git a/frontend/web/static/src/core/browser/title_service.js b/frontend/web/static/src/core/browser/title_service.js new file mode 100644 index 0000000..3dbe69d --- /dev/null +++ b/frontend/web/static/src/core/browser/title_service.js @@ -0,0 +1,60 @@ +import { registry } from "../registry"; + +export const titleService = { + start() { + const titleCounters = {}; + const titleParts = {}; + + function getParts() { + return Object.assign({}, titleParts); + } + + function setCounters(counters) { + for (const key in counters) { + const val = counters[key]; + if (!val) { + delete titleCounters[key]; + } else { + titleCounters[key] = val; + } + } + updateTitle(); + } + + function setParts(parts) { + for (const key in parts) { + const val = parts[key]; + if (!val) { + delete titleParts[key]; + } else { + titleParts[key] = val; + } + } + updateTitle(); + } + + function updateTitle() { + const counter = Object.values(titleCounters).reduce((acc, count) => acc + count, 0); + const name = Object.values(titleParts).join(" - ") || "Odoo"; + if (!counter) { + document.title = name; + } else { + document.title = `(${counter}) ${name}`; + } + } + + return { + /** + * @returns {string} + */ + get current() { + return document.title; + }, + getParts, + setCounters, + setParts, + }; + }, +}; + +registry.category("services").add("title", titleService); diff --git a/frontend/web/static/src/core/checkbox/checkbox.js b/frontend/web/static/src/core/checkbox/checkbox.js new file mode 100644 index 0000000..d9d3284 --- /dev/null +++ b/frontend/web/static/src/core/checkbox/checkbox.js @@ -0,0 +1,98 @@ +import { useHotkey } from "../hotkeys/hotkey_hook"; + +import { Component, useRef } from "@odoo/owl"; + +/** + * Custom checkbox + * + * + * Change the label text + * + * + * @extends Component + */ + +export class CheckBox extends Component { + static template = "web.CheckBox"; + static nextId = 1; + static defaultProps = { + onChange: () => {}, + }; + static props = { + id: { + type: true, + optional: true, + }, + disabled: { + type: Boolean, + optional: true, + }, + value: { + type: Boolean, + optional: true, + }, + slots: { + type: Object, + optional: true, + }, + onChange: { + type: Function, + optional: true, + }, + className: { + type: String, + optional: true, + }, + name: { + type: String, + optional: true, + }, + indeterminate: { + type: Boolean, + optional: true, + }, + }; + + setup() { + this.id = `checkbox-comp-${CheckBox.nextId++}`; + this.rootRef = useRef("root"); + + // Make it toggleable through the Enter hotkey + // when the focus is inside the root element + useHotkey( + "Enter", + ({ area }) => { + const oldValue = area.querySelector("input").checked; + this.props.onChange(!oldValue); + }, + { area: () => this.rootRef.el, bypassEditableProtection: true } + ); + } + + onClick(ev) { + if (ev.composedPath().find((el) => ["INPUT", "LABEL"].includes(el.tagName))) { + // The onChange will handle these cases. + ev.stopPropagation(); + return; + } + + // Reproduce the click event behavior as if it comes from the input element. + const input = this.rootRef.el.querySelector("input"); + input.focus(); + if (!this.props.disabled) { + ev.stopPropagation(); + input.checked = !input.checked; + this.props.onChange(input.checked); + } + } + + onChange(ev) { + if (!this.props.disabled) { + this.props.onChange(ev.target.checked); + } + } +} diff --git a/frontend/web/static/src/core/checkbox/checkbox.scss b/frontend/web/static/src/core/checkbox/checkbox.scss new file mode 100644 index 0000000..5be66ef --- /dev/null +++ b/frontend/web/static/src/core/checkbox/checkbox.scss @@ -0,0 +1,3 @@ +.o-checkbox { + width: fit-content; +} diff --git a/frontend/web/static/src/core/checkbox/checkbox.xml b/frontend/web/static/src/core/checkbox/checkbox.xml new file mode 100644 index 0000000..ba3bd59 --- /dev/null +++ b/frontend/web/static/src/core/checkbox/checkbox.xml @@ -0,0 +1,22 @@ + + + + +
+ + +
+
+ +
diff --git a/frontend/web/static/src/core/code_editor/code_editor.js b/frontend/web/static/src/core/code_editor/code_editor.js new file mode 100644 index 0000000..fff60df --- /dev/null +++ b/frontend/web/static/src/core/code_editor/code_editor.js @@ -0,0 +1,180 @@ +import { Component, onMounted, onWillStart, useEffect, useRef, useState, status } from "@odoo/owl"; +import { loadBundle } from "@web/core/assets"; + +export class CodeEditor extends Component { + static template = "web.CodeEditor"; + static components = {}; + static props = { + mode: { + type: String, + optional: true, + validate: (mode) => CodeEditor.MODES.includes(mode), + }, + value: { validate: (v) => typeof v === "string", optional: true }, + readonly: { type: Boolean, optional: true }, + onChange: { type: Function, optional: true }, + onBlur: { type: Function, optional: true }, + class: { type: String, optional: true }, + theme: { + type: String, + optional: true, + validate: (theme) => CodeEditor.THEMES.includes(theme), + }, + maxLines: { type: Number, optional: true }, + sessionId: { type: [Number, String], optional: true }, + initialCursorPosition: { type: Object, optional: true }, + showLineNumbers: { type: Boolean, optional: true }, + }; + static defaultProps = { + readonly: false, + value: "", + onChange: () => {}, + class: "", + theme: "", + sessionId: 1, + showLineNumbers: true, + }; + + static MODES = ["javascript", "xml", "qweb", "scss", "python"]; + static THEMES = ["", "monokai"]; + + setup() { + this.editorRef = useRef("editorRef"); + this.state = useState({ + activeMode: undefined, + }); + + onWillStart(async () => await loadBundle("web.ace_lib")); + + const sessions = {}; + // The ace library triggers the "change" event even if the change is + // programmatic. Even worse, it triggers 2 "change" events in that case, + // one with the empty string, and one with the new value. We only want + // to notify the parent of changes done by the user, in the UI, so we + // use this flag to filter out noisy "change" events. + let ignoredAceChange = false; + useEffect( + (el) => { + if (!el) { + return; + } + + // keep in closure + const aceEditor = window.ace.edit(el); + this.aceEditor = aceEditor; + + this.aceEditor.setOptions({ + maxLines: this.props.maxLines, + showPrintMargin: false, + useWorker: false, + }); + this.aceEditor.$blockScrolling = true; + + this.aceEditor.on("changeMode", () => { + this.state.activeMode = this.aceEditor.getSession().$modeId.split("/").at(-1); + }); + + const session = aceEditor.getSession(); + if (!sessions[this.props.sessionId]) { + sessions[this.props.sessionId] = session; + } + session.setValue(this.props.value); + session.on("change", () => { + if (this.props.onChange && !ignoredAceChange) { + this.props.onChange( + this.aceEditor.getValue(), + this.aceEditor.getCursorPosition() + ); + } + }); + this.aceEditor.on("blur", () => { + if (this.props.onBlur) { + this.props.onBlur(); + } + }); + + return () => { + aceEditor.destroy(); + }; + }, + () => [this.editorRef.el] + ); + + useEffect( + (theme) => this.aceEditor.setTheme(theme ? `ace/theme/${theme}` : ""), + () => [this.props.theme] + ); + + useEffect( + (readonly, showLineNumbers) => { + this.aceEditor.setOptions({ + readOnly: readonly, + highlightActiveLine: !readonly, + highlightGutterLine: !readonly, + }); + + this.aceEditor.renderer.setOptions({ + displayIndentGuides: !readonly, + showGutter: !readonly && showLineNumbers, + }); + + this.aceEditor.renderer.$cursorLayer.element.style.display = readonly + ? "none" + : "block"; + }, + () => [this.props.readonly, this.props.showLineNumbers] + ); + + useEffect( + (sessionId, mode, value) => { + let session = sessions[sessionId]; + if (session) { + if (session.getValue() !== value) { + ignoredAceChange = true; + session.setValue(value); + ignoredAceChange = false; + } + } else { + session = new window.ace.EditSession(value); + session.setUndoManager(new window.ace.UndoManager()); + session.setOptions({ + useWorker: false, + tabSize: 2, + useSoftTabs: true, + }); + session.on("change", () => { + if (this.props.onChange && !ignoredAceChange) { + this.props.onChange( + this.aceEditor.getValue(), + this.aceEditor.getCursorPosition() + ); + } + }); + sessions[sessionId] = session; + } + session.setMode(mode ? `ace/mode/${mode}` : ""); + this.aceEditor.setSession(session); + }, + () => [this.props.sessionId, this.props.mode, this.props.value] + ); + + const initialCursorPosition = this.props.initialCursorPosition; + if (initialCursorPosition) { + onMounted(() => { + // Wait for ace to be fully operational + window.requestAnimationFrame(() => { + if (status(this) != "destroyed" && this.aceEditor) { + this.aceEditor.focus(); + const { row, column } = initialCursorPosition; + const pos = { + row: row || 0, + column: column || 0, + }; + this.aceEditor.selection.moveToPosition(pos); + this.aceEditor.renderer.scrollCursorIntoView(pos, 0.5); + } + }); + }); + } + } +} diff --git a/frontend/web/static/src/core/code_editor/code_editor.xml b/frontend/web/static/src/core/code_editor/code_editor.xml new file mode 100644 index 0000000..f9e0285 --- /dev/null +++ b/frontend/web/static/src/core/code_editor/code_editor.xml @@ -0,0 +1,8 @@ + + + + +
+ + + diff --git a/frontend/web/static/src/core/color_picker/color_picker.js b/frontend/web/static/src/core/color_picker/color_picker.js new file mode 100644 index 0000000..9fd25ae --- /dev/null +++ b/frontend/web/static/src/core/color_picker/color_picker.js @@ -0,0 +1,362 @@ +import { Component, useEffect, useRef, useState } from "@odoo/owl"; +import { CustomColorPicker } from "@web/core/color_picker/custom_color_picker/custom_color_picker"; +import { usePopover } from "@web/core/popover/popover_hook"; +import { isCSSColor, isColorGradient, normalizeCSSColor } from "@web/core/utils/colors"; +import { cookie } from "@web/core/browser/cookie"; +import { POSITION_BUS } from "../position/position_hook"; +import { registry } from "../registry"; + +// These colors are already normalized as per normalizeCSSColor in @web/legacy/js/widgets/colorpicker +export const DEFAULT_COLORS = [ + ["#000000", "#424242", "#636363", "#9C9C94", "#CEC6CE", "#EFEFEF", "#F7F7F7", "#FFFFFF"], + ["#FF0000", "#FF9C00", "#FFFF00", "#00FF00", "#00FFFF", "#0000FF", "#9C00FF", "#FF00FF"], + ["#F7C6CE", "#FFE7CE", "#FFEFC6", "#D6EFD6", "#CEDEE7", "#CEE7F7", "#D6D6E7", "#E7D6DE"], + ["#E79C9C", "#FFC69C", "#FFE79C", "#B5D6A5", "#A5C6CE", "#9CC6EF", "#B5A5D6", "#D6A5BD"], + ["#E76363", "#F7AD6B", "#FFD663", "#94BD7B", "#73A5AD", "#6BADDE", "#8C7BC6", "#C67BA5"], + ["#CE0000", "#E79439", "#EFC631", "#6BA54A", "#4A7B8C", "#3984C6", "#634AA5", "#A54A7B"], + ["#9C0000", "#B56308", "#BD9400", "#397B21", "#104A5A", "#085294", "#311873", "#731842"], + ["#630000", "#7B3900", "#846300", "#295218", "#083139", "#003163", "#21104A", "#4A1031"], +]; + +export const DEFAULT_GRAYSCALES = { + solid: ["black", "900", "800", "600", "400", "200", "100", "white"], +}; + +// These CSS variables are defined in html_editor. +// Using ColorPicker without html_editor installed is extremely unlikely. +export const DEFAULT_THEME_COLOR_VARS = [ + "o-color-1", + "o-color-2", + "o-color-3", + "o-color-4", + "o-color-5", +]; + +export class ColorPicker extends Component { + static template = "web.ColorPicker"; + static components = { CustomColorPicker }; + static props = { + state: { + type: Object, + shape: { + selectedColor: String, + selectedColorCombination: { type: String, optional: true }, + getTargetedElements: { type: Function, optional: true }, + defaultTab: String, + selectedTab: { type: String, optional: true }, + // todo: remove the `mode` prop in master + mode: { type: String, optional: true }, + }, + }, + getUsedCustomColors: Function, + applyColor: Function, + applyColorPreview: Function, + applyColorResetPreview: Function, + editColorCombination: { type: Function, optional: true }, + setOnCloseCallback: { type: Function, optional: true }, + setOperationCallbacks: { type: Function, optional: true }, + enabledTabs: { type: Array, optional: true }, + colorPrefix: { type: String }, + cssVarColorPrefix: { type: String, optional: true }, + defaultOpacity: { type: Number, optional: true }, + grayscales: { type: Object, optional: true }, + noTransparency: { type: Boolean, optional: true }, + close: { type: Function, optional: true }, + className: { type: String, optional: true }, + useDefaultThemeColors: { type: Boolean, optional: true }, + }; + static defaultProps = { + close: () => {}, + defaultOpacity: 100, + enabledTabs: ["solid", "custom"], + cssVarColorPrefix: "", + setOnCloseCallback: () => {}, + useDefaultThemeColors: true, + }; + + setup() { + this.tabs = registry + .category("color_picker_tabs") + .getAll() + .filter((tab) => this.props.enabledTabs.includes(tab.id)); + this.root = useRef("root"); + + this.DEFAULT_COLORS = DEFAULT_COLORS; + this.grayscales = Object.assign({}, DEFAULT_GRAYSCALES, this.props.grayscales); + this.DEFAULT_THEME_COLOR_VARS = this.props.useDefaultThemeColors + ? DEFAULT_THEME_COLOR_VARS + : []; + this.defaultColorSet = this.getDefaultColorSet(); + this.defaultColor = this.props.state.selectedColor; + this.focusedBtn = null; + this.onApplyCallback = () => {}; + this.onPreviewRevertCallback = () => {}; + this.getPreviewColor = () => {}; + + this.state = useState({ + activeTab: this.props.state.selectedTab || this.getDefaultTab(), + currentCustomColor: this.props.state.selectedColor, + currentColorPreview: undefined, + showGradientPicker: false, + }); + this.usedCustomColors = this.props.getUsedCustomColors(); + useEffect( + () => { + // Recompute the positioning of the popover if any. + this.env[POSITION_BUS]?.trigger("update"); + }, + () => [this.state.activeTab] + ); + } + + getDefaultTab() { + if (this.props.enabledTabs.includes(this.props.state.defaultTab)) { + return this.props.state.defaultTab; + } + return this.props.enabledTabs[0]; + } + + get selectedColor() { + return this.props.state.selectedColor; + } + + get isDarkTheme() { + return cookie.get("color_scheme") === "dark"; + } + + setTab(tab) { + this.state.activeTab = tab; + // Reset the preview revert callback, as it is tab-specific. + this.setOperationCallbacks({ onPreviewRevertCallback: () => {} }); + this.applyColorResetPreview(); + } + + processColorFromEvent(ev) { + const target = this.getTarget(ev); + let color = target.dataset.color || ""; + if (color && isColorCombination(color)) { + return color; + } + if (color && !isCSSColor(color) && !isColorGradient(color)) { + color = this.props.colorPrefix + color; + } + return color; + } + /** + * @param {Object} cbs - callbacks + * @param {Function} cbs.onApplyCallback + * @param {Function} cbs.onPreviewRevertCallback + */ + setOperationCallbacks(cbs) { + // The gradient colorpicker has a nested ColorPicker. We need to use the + // `setOperationCallbacks` from the parent ColorPicker for it to be + // impacted. + if (this.props.setOperationCallbacks) { + this.props.setOperationCallbacks(cbs); + } + if (cbs.onApplyCallback) { + this.onApplyCallback = cbs.onApplyCallback; + } + if (cbs.onPreviewRevertCallback) { + this.onPreviewRevertCallback = cbs.onPreviewRevertCallback; + } + if (cbs.getPreviewColor) { + this.getPreviewColor = cbs.getPreviewColor; + } + } + + applyColor(color) { + this.state.currentCustomColor = color; + this.props.applyColor(color); + this.defaultColorSet = this.getDefaultColorSet(); + this.onApplyCallback(); + } + + onColorApply(ev) { + if (!this.isColorButton(this.getTarget(ev))) { + return; + } + const color = this.processColorFromEvent(ev); + this.applyColor(color); + this.props.close(); + } + + applyColorResetPreview() { + this.props.applyColorResetPreview(); + this.state.currentColorPreview = undefined; + this.onPreviewRevertCallback(); + } + + onColorPreview(ev) { + const color = ev.hex || ev.gradient || this.processColorFromEvent(ev); + this.props.applyColorPreview(color); + this.state.currentColorPreview = this.getPreviewColor(); + } + + onColorHover(ev) { + if (!this.isColorButton(this.getTarget(ev))) { + return; + } + this.onColorPreview(ev); + } + + onColorHoverOut(ev) { + if (!this.isColorButton(this.getTarget(ev))) { + return; + } + this.applyColorResetPreview(); + } + getTarget(ev) { + const target = ev.target.closest(`[data-color]`); + return this.root.el.contains(target) ? target : ev.target; + } + + onColorFocusin(ev) { + // In the editor color picker, the preview and reset reapply the + // selection, which can remove the focus from the current button (if the + // node is recreated). We need to force the focus and break the infinite + // loop that it could trigger. + if (this.focusedBtn === ev.target) { + this.focusedBtn = null; + return; + } + this.focusedBtn = ev.target; + this.onColorHover(ev); + if (document.activeElement !== ev.target) { + // The focus was lost during revert. Reset it where it should be. + ev.target.focus(); + } + } + + onColorFocusout(ev) { + if (!ev.relatedTarget || !this.isColorButton(ev.relatedTarget)) { + // Do not trigger a revert if we are in the focus loop (i.e. focus + // a button > selection is reset > focusout). Otherwise, the + // relatedTarget should always be one of the colorpicker's buttons. + return; + } + const activeEl = document.activeElement; + this.applyColorResetPreview(); + if (document.activeElement !== activeEl) { + // The focus was lost during revert. Reset it where it should be. + ev.relatedTarget.focus(); + } + } + + getDefaultColorSet() { + if (!this.props.state.selectedColor) { + return; + } + let defaultColors = this.props.enabledTabs.includes("solid") + ? this.DEFAULT_THEME_COLOR_VARS + : []; + for (const grayscale of Object.values(this.grayscales)) { + defaultColors = defaultColors.concat(grayscale); + } + + const targetedElement = + this.props.state.getTargetedElements?.()[0] || document.documentElement; + const selectedColor = this.props.state.selectedColor.toUpperCase(); + const htmlStyle = + targetedElement.ownerDocument.defaultView.getComputedStyle(targetedElement); + + for (const color of defaultColors) { + const cssVar = normalizeCSSColor(htmlStyle.getPropertyValue(`--${color}`)); + if (cssVar?.toUpperCase() === selectedColor) { + return color; + } + } + + return false; + } + + colorPickerNavigation(ev) { + const { target, key } = ev; + if (!target.classList.contains("o_color_button")) { + return; + } + if (!["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown"].includes(key)) { + return; + } + + let targetBtn; + if (key === "ArrowRight") { + targetBtn = target.nextElementSibling; + } else if (key === "ArrowLeft") { + targetBtn = target.previousElementSibling; + } else if (key === "ArrowUp" || key === "ArrowDown") { + const buttonIndex = [...target.parentElement.children].indexOf(target); + const nbColumns = getComputedStyle(target).getPropertyValue( + "--o-color-picker-grid-columns" + ); + targetBtn = + target.parentElement.children[ + buttonIndex + (key === "ArrowUp" ? -1 : 1) * nbColumns + ]; + if (!targetBtn) { + const row = + key === "ArrowUp" + ? target.parentElement.previousElementSibling + : target.parentElement.nextElementSibling; + if (row?.matches(".o_color_section, .o_colorpicker_section")) { + targetBtn = row.children[buttonIndex]; + } + } + } + if (targetBtn && targetBtn.classList.contains("o_color_button")) { + targetBtn.focus(); + } + } + + isColorButton(targetEl) { + return targetEl.tagName === "BUTTON" && !targetEl.matches(".o_colorpicker_ignore"); + } +} + +export function useColorPicker(refName, props, options = {}) { + // Callback to be overridden by child components (e.g. custom color picker). + let onCloseCallback = () => {}; + const setOnCloseCallback = (cb) => { + onCloseCallback = cb; + }; + props.setOnCloseCallback = setOnCloseCallback; + if (options.onClose) { + const onClose = options.onClose; + options.onClose = () => { + onCloseCallback(); + onClose(); + }; + } + + const colorPicker = usePopover(ColorPicker, options); + const root = useRef(refName); + + function onClick() { + colorPicker.isOpen ? colorPicker.close() : colorPicker.open(root.el, props); + } + + useEffect( + (el) => { + if (!el) { + return; + } + el.addEventListener("click", onClick); + return () => { + el.removeEventListener("click", onClick); + }; + }, + () => [root.el] + ); + + return colorPicker; +} + +/** + * Checks if a given string is a color combination. + * + * @param {string} color + * @returns {boolean} + */ +function isColorCombination(color) { + return color.startsWith("o_cc"); +} diff --git a/frontend/web/static/src/core/color_picker/color_picker.scss b/frontend/web/static/src/core/color_picker/color_picker.scss new file mode 100644 index 0000000..2c3dabb --- /dev/null +++ b/frontend/web/static/src/core/color_picker/color_picker.scss @@ -0,0 +1,94 @@ +$o-we-toolbar-bg: #FFF !default; +$o-we-toolbar-color-text: #2b2b33 !default; // Same as $o-we-bg-light +$o-we-item-spacing: 8px !default; +$o-we-color-success: #00ff9e !default; + +.o_font_color_selector { + @include o-input-number-no-arrows(); + --bg: #{$o-we-toolbar-bg}; + --text-rgb: #{red($o-we-toolbar-color-text)}, #{green($o-we-toolbar-color-text)}, #{blue($o-we-toolbar-color-text)}; + --border-rgb: var(--text-rgb); + width: 208px; + max-height: inherit; + overflow-y: auto; + border-radius: inherit; + background-color: inherit; + box-shadow: $box-shadow; + &::-webkit-scrollbar { + display: none; + } +} + +.o_color_button { + width: 23px; + height: 22px; + box-shadow: inset 0 0 0 1px rgba(var(--border-rgb), .5); + margin: 0.5px; + + &:focus, + &:hover { + transform: scale(1.1); + } +} + +.o_color_picker_button { + @extend %o-preview-alpha-background; + + &:not(.selected):focus, + &:not(.selected):hover { + outline: solid $o-enterprise-action-color; + z-index: 1; + transition: transform 0.1s ease-out; + } +} + +.o_font_color_selector { + .btn-tab { + min-width: 57px; + padding: 3px; + font-size: 12px; + } + + .o_color_picker_button.selected { + border: 3px solid $o-enterprise-action-color !important; + } +} + +.o_font_color_selector .o_colorpicker_section { + margin-bottom: 3px; +} + +.o_font_color_selector { + --o-color-picker-grid-columns: 8; + .o_colorpicker_section, .o_color_section { + display: grid; + grid-template-columns: repeat(var(--o-color-picker-grid-columns), 1fr); + } +} + +.o_font_color_selector .o_colorpicker_widget { + width: 100%; + margin-top: 2px; + .o_hex_input { + border: 1px solid !important; + padding: 0 2px !important; + width: 10ch !important; + opacity: 0.7; + } +} + +:root { + @each $color, $value in $grays { + @include print-variable($color, $value); + } +} + +.color-combination-button.selected h1 { + &::before { + content: "\f00c"; + margin-right: $o-we-item-spacing; + font-size: 0.8em; + font-family: FontAwesome; + color: $o-we-color-success; + } +} diff --git a/frontend/web/static/src/core/color_picker/color_picker.xml b/frontend/web/static/src/core/color_picker/color_picker.xml new file mode 100644 index 0000000..b9d431e --- /dev/null +++ b/frontend/web/static/src/core/color_picker/color_picker.xml @@ -0,0 +1,51 @@ + + +
+
+ + + +
+
+ + +
+ + + diff --git a/frontend/web/static/src/core/color_picker/custom_color_picker/custom_color_picker.js b/frontend/web/static/src/core/color_picker/custom_color_picker/custom_color_picker.js new file mode 100644 index 0000000..2e741ec --- /dev/null +++ b/frontend/web/static/src/core/color_picker/custom_color_picker/custom_color_picker.js @@ -0,0 +1,689 @@ +import { getActiveHotkey } from "@web/core/hotkeys/hotkey_service"; +import { _t } from "@web/core/l10n/translation"; +import { + convertCSSColorToRgba, + convertHslToRgb, + convertRgbaToCSSColor, + convertRgbToHsl, + normalizeCSSColor, +} from "@web/core/utils/colors"; +import { uniqueId } from "@web/core/utils/functions"; +import { clamp } from "@web/core/utils/numbers"; +import { debounce, useThrottleForAnimation } from "@web/core/utils/timing"; + +import { Component, onMounted, onWillUpdateProps, useExternalListener, useRef } from "@odoo/owl"; + +const ARROW_KEYS = ["arrowup", "arrowdown", "arrowleft", "arrowright"]; +const SLIDER_KEYS = [...ARROW_KEYS, "pageup", "pagedown", "home", "end"]; + +const DEFAULT_COLOR = "#FF0000"; + +export class CustomColorPicker extends Component { + static template = "web.CustomColorPicker"; + static props = { + document: { type: true, optional: true }, + defaultColor: { type: String, optional: true }, + selectedColor: { type: String, optional: true }, + noTransparency: { type: Boolean, optional: true }, + stopClickPropagation: { type: Boolean, optional: true }, + onColorSelect: { type: Function, optional: true }, + onColorPreview: { type: Function, optional: true }, + onInputEnter: { type: Function, optional: true }, + defaultOpacity: { type: Number, optional: true }, + setOnCloseCallback: { type: Function, optional: true }, + setOperationCallbacks: { type: Function, optional: true }, + }; + static defaultProps = { + document: window.document, + defaultColor: DEFAULT_COLOR, + defaultOpacity: 100, + noTransparency: false, + stopClickPropagation: false, + onColorSelect: () => {}, + onColorPreview: () => {}, + onInputEnter: () => {}, + }; + + setup() { + this.pickerFlag = false; + this.sliderFlag = false; + this.opacitySliderFlag = false; + if (this.props.defaultOpacity > 0 && this.props.defaultOpacity <= 1) { + this.props.defaultOpacity *= 100; + } + if (this.props.defaultColor.length <= 7) { + const opacityHex = Math.round((this.props.defaultOpacity / 100) * 255) + .toString(16) + .padStart(2, "0"); + this.props.defaultColor += opacityHex; + } + this.colorComponents = {}; + this.uniqueId = uniqueId("colorpicker"); + this.selectedHexValue = ""; + this.shouldSetSelectedColor = false; + this.lastFocusedSliderEl = undefined; + if (!this.props.selectedColor) { + this.props.selectedColor = this.props.defaultColor; + } + this.debouncedOnChangeInputs = debounce(this.onChangeInputs.bind(this), 10, true); + + this.elRef = useRef("el"); + this.colorPickerAreaRef = useRef("colorPickerArea"); + this.colorPickerPointerRef = useRef("colorPickerPointer"); + this.colorSliderRef = useRef("colorSlider"); + this.colorSliderPointerRef = useRef("colorSliderPointer"); + this.opacitySliderRef = useRef("opacitySlider"); + this.opacitySliderPointerRef = useRef("opacitySliderPointer"); + + // Need to be bound on all documents to work in all possible cases (we + // have to be able to start dragging/moving from the colorpicker to + // anywhere on the screen, crossing iframes). + const documents = [ + window.top, + ...Array.from(window.top.frames).filter((frame) => { + try { + const document = frame.document; + return !!document; + } catch { + // We cannot access the document (cross origin). + return false; + } + }), + ].map((w) => w.document); + this.throttleOnPointerMove = useThrottleForAnimation((ev) => { + this.onPointerMovePicker(ev); + this.onPointerMoveSlider(ev); + this.onPointerMoveOpacitySlider(ev); + }); + + for (const doc of documents) { + useExternalListener(doc, "pointermove", this.throttleOnPointerMove); + useExternalListener(doc, "pointerup", this.onPointerUp.bind(this)); + useExternalListener(doc, "keydown", this.onEscapeKeydown.bind(this), { capture: true }); + } + // Apply the previewed custom color when the popover is closed. + this.props.setOnCloseCallback?.(() => { + if (this.shouldSetSelectedColor) { + this._colorSelected(); + } + }); + this.props.setOperationCallbacks?.({ + getPreviewColor: () => { + if (this.shouldSetSelectedColor) { + return this.colorComponents.hex; + } + }, + onApplyCallback: () => { + this.shouldSetSelectedColor = false; + }, + // Reapply the current custom color preview after reverting a preview. + // Typical usecase: 1) modify the custom color, 2) hover one of the + // black-white tints, 3) hover out. + onPreviewRevertCallback: () => { + if (this.previewActive && this.shouldSetSelectedColor) { + this.props.onColorPreview(this.colorComponents); + } + }, + }); + onMounted(async () => { + const rgba = + convertCSSColorToRgba(this.props.selectedColor) || + convertCSSColorToRgba(this.props.defaultColor); + if (rgba) { + this._updateRgba(rgba.red, rgba.green, rgba.blue, rgba.opacity); + } + + this.previewActive = true; + this._updateUI(); + }); + onWillUpdateProps((newProps) => { + const newSelectedColor = newProps.selectedColor + ? newProps.selectedColor + : newProps.defaultColor; + if (normalizeCSSColor(newSelectedColor) !== this.colorComponents.cssColor) { + this.setSelectedColor(newSelectedColor); + } + }); + } + + /** + * Sets the currently selected color + * + * @param {string} color rgb[a] + */ + setSelectedColor(color) { + const rgba = convertCSSColorToRgba(color); + if (rgba) { + const oldPreviewActive = this.previewActive; + this.previewActive = false; + this._updateRgba(rgba.red, rgba.green, rgba.blue, rgba.opacity); + this.previewActive = oldPreviewActive; + this._updateUI(); + } + } + /** + * @param {string[]} allowedKeys + * @returns {string[]} allowed keys + modifiers + */ + getAllowedHotkeys(allowedKeys) { + return allowedKeys.flatMap((key) => [key, `control+${key}`]); + } + /** + * @param {HTMLElement} el + */ + setLastFocusedSliderEl(el) { + this.lastFocusedSliderEl = el; + document.activeElement.blur(); + } + + get el() { + return this.elRef.el; + } + /** + * @param {string} hotkey + * @param {number} value + * @param {Object} [options] + * @param {number} [options.min=0] + * @param {number} [options.max=100] + * @param {number} [options.defaultStep=10] - default step + * @param {number} [options.modifierStep=1] - step when holding ctrl+key + * @param {number} [options.leap=20] - step for pageup / pagedown + * @returns {number} updated and clamped value + */ + handleRangeKeydownValue( + hotkey, + value, + { min = 0, max = 100, defaultStep = 10, modifierStep = 1, leap = 20 } = {} + ) { + let step = defaultStep; + if (hotkey.startsWith("control+")) { + step = modifierStep; + } + const mainKey = hotkey.replace("control+", ""); + if (mainKey === "pageup" || mainKey === "pagedown") { + step = leap; + } + if (["arrowup", "arrowright", "pageup"].includes(mainKey)) { + value += step; + } else if (["arrowdown", "arrowleft", "pagedown"].includes(mainKey)) { + value -= step; + } else if (mainKey === "home") { + value = min; + } else if (mainKey === "end") { + value = max; + } + return clamp(value, min, max); + } + /** + * Selects and applies a currently previewed color if "Enter" was pressed. + * + * @param {String} hotkey + */ + selectColorOnEnter(hotkey) { + if (hotkey === "enter" && this.shouldSetSelectedColor) { + this.pickerFlag = false; + this.sliderFlag = false; + this.opacitySliderFlag = false; + this._colorSelected(); + } + } + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * Updates input values, color preview, picker and slider pointer positions. + * + * @private + */ + _updateUI() { + // Update inputs + for (const [color, value] of Object.entries(this.colorComponents)) { + const input = this.el.querySelector(`.o_${color}_input`); + if (input) { + input.value = value; + } + } + + // Update picker area and picker pointer position + const colorPickerArea = this.colorPickerAreaRef.el; + colorPickerArea.style.backgroundColor = `hsl(${this.colorComponents.hue}, 100%, 50%)`; + const top = ((100 - this.colorComponents.lightness) * colorPickerArea.clientHeight) / 100; + const left = (this.colorComponents.saturation * colorPickerArea.clientWidth) / 100; + + const colorpickerPointer = this.colorPickerPointerRef.el; + colorpickerPointer.style.top = top - 5 + "px"; + colorpickerPointer.style.left = left - 5 + "px"; + colorpickerPointer.setAttribute( + "aria-label", + _t("Saturation: %(saturationLvl)s %. Brightness: %(brightnessLvl)s %", { + saturationLvl: this.colorComponents.saturation?.toFixed(2) || "0", + brightnessLvl: this.colorComponents.lightness?.toFixed(2) || "0", + }) + ); + + // Update color slider position + const colorSlider = this.colorSliderRef.el; + const height = colorSlider.clientHeight; + const y = (this.colorComponents.hue * height) / 360; + this.colorSliderPointerRef.el.style.bottom = `${Math.round(y - 4)}px`; + this.colorSliderPointerRef.el.setAttribute( + "aria-valuenow", + this.colorComponents.hue.toFixed(2) + ); + + if (!this.props.noTransparency) { + // Update opacity slider position + const opacitySlider = this.opacitySliderRef.el; + const heightOpacity = opacitySlider.clientHeight; + const z = heightOpacity * (1 - this.colorComponents.opacity / 100.0); + this.opacitySliderPointerRef.el.style.top = `${Math.round(z - 2)}px`; + this.opacitySliderPointerRef.el.setAttribute( + "aria-valuenow", + this.colorComponents.opacity.toFixed(2) + ); + + // Add gradient color on opacity slider + const sliderColor = this.colorComponents.hex.slice(0, 7); + opacitySlider.style.background = `linear-gradient(${sliderColor} 0%, transparent 100%)`; + } + } + /** + * Updates colors according to given hex value. Opacity is left unchanged. + * + * @private + * @param {string} hex - hexadecimal code + */ + _updateHex(hex) { + const rgb = convertCSSColorToRgba(hex); + if (!rgb) { + return; + } + Object.assign( + this.colorComponents, + { hex: hex }, + rgb, + convertRgbToHsl(rgb.red, rgb.green, rgb.blue) + ); + this._updateCssColor(); + } + /** + * Updates colors according to given RGB values. + * + * @private + * @param {integer} r + * @param {integer} g + * @param {integer} b + * @param {integer} [a] + */ + _updateRgba(r, g, b, a) { + // Remove full transparency in case some lightness is added + const opacity = a || this.colorComponents.opacity; + if (opacity < 0.1 && (r > 0.1 || g > 0.1 || b > 0.1)) { + a = this.props.defaultOpacity; + } + + const hex = convertRgbaToCSSColor(r, g, b, a); + if (!hex) { + return; + } + Object.assign( + this.colorComponents, + { red: r, green: g, blue: b }, + a === undefined ? {} : { opacity: a }, + { hex: hex }, + convertRgbToHsl(r, g, b) + ); + this._updateCssColor(); + } + /** + * Updates colors according to given HSL values. + * + * @private + * @param {integer} h + * @param {integer} s + * @param {integer} l + */ + _updateHsl(h, s, l) { + // Remove full darkness/brightness and non-saturation in case hue is changed + if (0.1 < Math.abs(h - this.colorComponents.hue)) { + if (l < 0.1 || 99.9 < l) { + l = 50; + } + if (s < 0.1) { + s = 100; + } + } + // Remove full transparency in case some lightness is added + let a = this.colorComponents.opacity; + if (a < 0.1 && l > 0.1) { + a = this.props.defaultOpacity; + } + + const rgb = convertHslToRgb(h, s, l); + if (!rgb) { + return; + } + // We receive an hexa as we ignore the opacity + const hex = convertRgbaToCSSColor(rgb.red, rgb.green, rgb.blue, a); + Object.assign( + this.colorComponents, + { hue: h, saturation: s, lightness: l }, + rgb, + { hex: hex }, + { opacity: a } + ); + this._updateCssColor(); + } + /** + * Updates color opacity. + * + * @private + * @param {integer} a + */ + _updateOpacity(a) { + if (a < 0 || a > 100) { + return; + } + Object.assign(this.colorComponents, { opacity: a }); + const r = this.colorComponents.red; + const g = this.colorComponents.green; + const b = this.colorComponents.blue; + Object.assign(this.colorComponents, { hex: convertRgbaToCSSColor(r, g, b, a) }); + this._updateCssColor(); + } + /** + * Trigger an event to annonce that the widget value has changed + * + * @private + */ + _colorSelected() { + this.props.onColorSelect(this.colorComponents); + } + /** + * Updates css color representation. + * + * @private + */ + _updateCssColor() { + const r = this.colorComponents.red; + const g = this.colorComponents.green; + const b = this.colorComponents.blue; + const a = this.colorComponents.opacity; + Object.assign(this.colorComponents, { cssColor: convertRgbaToCSSColor(r, g, b, a) }); + if (this.previewActive) { + this.props.onColorPreview(this.colorComponents); + } + } + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + onKeydown(ev) { + if (ev.key === "Enter") { + if (ev.target.tagName === "INPUT") { + this.onChangeInputs(ev); + } + ev.preventDefault(); + this.props.onInputEnter(ev); + } + } + /** + * @param {Event} ev + */ + onClick(ev) { + if (this.props.stopClickPropagation) { + ev.stopPropagation(); + } + //TODO: we should remove it with legacy web_editor + ev.__isColorpickerClick = true; + + if (ev.target.dataset.colorMethod === "hex" && !this.selectedHexValue) { + ev.target.select(); + this.selectedHexValue = ev.target.value; + return; + } + this.selectedHexValue = ""; + } + onPointerUp() { + if (this.pickerFlag || this.sliderFlag || this.opacitySliderFlag) { + this.shouldSetSelectedColor = true; + this._updateCssColor(); + } + this.pickerFlag = false; + this.sliderFlag = false; + this.opacitySliderFlag = false; + + if (this.lastFocusedSliderEl) { + this.lastFocusedSliderEl.focus(); + this.lastFocusedSliderEl = undefined; + } + } + /** + * Removes the close callback on Escape, so that a preview is cancelled with + * escape instead of being applied. + * + * @param {KeydownEvent} ev + */ + onEscapeKeydown(ev) { + const hotkey = getActiveHotkey(ev); + if (hotkey === "escape") { + this.props.setOnCloseCallback?.(() => {}); + } + } + /** + * Updates color when the user starts clicking on the picker. + * + * @private + * @param {Event} ev + */ + onPointerDownPicker(ev) { + this.pickerFlag = true; + ev.preventDefault(); + this.onPointerMovePicker(ev); + this.setLastFocusedSliderEl(this.colorPickerPointerRef.el); + } + /** + * Updates saturation and lightness values on pointer drag over picker. + * + * @private + * @param {Event} ev + */ + onPointerMovePicker(ev) { + if (!this.pickerFlag) { + return; + } + + const colorPickerArea = this.colorPickerAreaRef.el; + const rect = colorPickerArea.getClientRects()[0]; + const top = ev.pageY - rect.top; + const left = ev.pageX - rect.left; + let saturation = Math.round((100 * left) / colorPickerArea.clientWidth); + let lightness = Math.round( + (100 * (colorPickerArea.clientHeight - top)) / colorPickerArea.clientHeight + ); + saturation = clamp(saturation, 0, 100); + lightness = clamp(lightness, 0, 100); + + this._updateHsl(this.colorComponents.hue, saturation, lightness); + this._updateUI(); + } + /** + * Updates saturation and lightness values on arrow keydown over picker. + * + * @private + * @param {Event} ev + */ + onPickerKeydown(ev) { + const hotkey = getActiveHotkey(ev); + this.selectColorOnEnter(hotkey); + if (!this.getAllowedHotkeys(ARROW_KEYS).includes(hotkey)) { + return; + } + let saturation = this.colorComponents.saturation; + let lightness = this.colorComponents.lightness; + let step = 10; + if (hotkey.startsWith("control+")) { + step = 1; + } + const mainKey = hotkey.replace("control+", ""); + if (mainKey === "arrowup") { + lightness += step; + } else if (mainKey === "arrowdown") { + lightness -= step; + } else if (mainKey === "arrowright") { + saturation += step; + } else if (mainKey === "arrowleft") { + saturation -= step; + } + lightness = clamp(lightness, 0, 100); + saturation = clamp(saturation, 0, 100); + + this._updateHsl(this.colorComponents.hue, saturation, lightness); + this._updateUI(); + this.shouldSetSelectedColor = true; + } + /** + * Updates color when user starts clicking on slider. + * + * @private + * @param {Event} ev + */ + onPointerDownSlider(ev) { + this.sliderFlag = true; + ev.preventDefault(); + this.onPointerMoveSlider(ev); + this.setLastFocusedSliderEl(this.colorSliderPointerRef.el); + } + /** + * Updates hue value on pointer drag over slider. + * + * @private + * @param {Event} ev + */ + onPointerMoveSlider(ev) { + if (!this.sliderFlag) { + return; + } + + const colorSlider = this.colorSliderRef.el; + const colorSliderRects = colorSlider.getClientRects(); + const y = colorSliderRects[0].height - (ev.pageY - colorSliderRects[0].top); + let hue = Math.round((360 * y) / colorSlider.clientHeight); + hue = clamp(hue, 0, 360); + + this._updateHsl(hue, this.colorComponents.saturation, this.colorComponents.lightness); + this._updateUI(); + } + /** + * Updates hue value on arrow keydown on slider. + * + * @param {Event} ev + */ + onSliderKeydown(ev) { + const hotkey = getActiveHotkey(ev); + this.selectColorOnEnter(hotkey); + if (!this.getAllowedHotkeys(SLIDER_KEYS).includes(hotkey)) { + return; + } + const hue = this.handleRangeKeydownValue(hotkey, this.colorComponents.hue, { + min: 0, + max: 360, + leap: 30, + }); + this._updateHsl(hue, this.colorComponents.saturation, this.colorComponents.lightness); + this._updateUI(); + this.shouldSetSelectedColor = true; + } + /** + * Updates opacity when user starts clicking on opacity slider. + * + * @private + * @param {Event} ev + */ + onPointerDownOpacitySlider(ev) { + this.opacitySliderFlag = true; + ev.preventDefault(); + this.onPointerMoveOpacitySlider(ev); + this.setLastFocusedSliderEl(this.opacitySliderPointerRef.el); + } + /** + * Updates opacity value on pointer drag over opacity slider. + * + * @private + * @param {Event} ev + */ + onPointerMoveOpacitySlider(ev) { + if (!this.opacitySliderFlag || this.props.noTransparency) { + return; + } + + const opacitySlider = this.opacitySliderRef.el; + const y = ev.pageY - opacitySlider.getClientRects()[0].top; + let opacity = Math.round(100 * (1 - y / opacitySlider.clientHeight)); + opacity = clamp(opacity, 0, 100); + + this._updateOpacity(opacity); + this._updateUI(); + } + /** + * Updates opacity value on arrow keydown on opacity slider. + * + * @param {Event} ev + */ + onOpacitySliderKeydown(ev) { + const hotkey = getActiveHotkey(ev); + this.selectColorOnEnter(hotkey); + if (!this.getAllowedHotkeys(SLIDER_KEYS).includes(hotkey)) { + return; + } + const opacity = this.handleRangeKeydownValue(hotkey, this.colorComponents.opacity); + + this._updateOpacity(opacity); + this._updateUI(); + this.shouldSetSelectedColor = true; + } + /** + * Called when input value is changed -> Updates UI: Set picker and slider + * position and set colors. + * + * @private + * @param {Event} ev + */ + onChangeInputs(ev) { + switch (ev.target.dataset.colorMethod) { + case "hex": + // Handled by the "input" event (see "onHexColorInput"). + return; + case "hsl": + this._updateHsl( + parseInt(this.el.querySelector(".o_hue_input").value), + parseInt(this.el.querySelector(".o_saturation_input").value), + parseInt(this.el.querySelector(".o_lightness_input").value) + ); + break; + } + this._updateUI(); + this._colorSelected(); + } + /** + * Called when the hex color input's input event is triggered. + * + * @private + * @param {Event} ev + */ + onHexColorInput(ev) { + const hexColorValue = ev.target.value.replaceAll("#", ""); + if (hexColorValue.length === 6 || hexColorValue.length === 8) { + this._updateHex(`#${hexColorValue}`); + this._updateUI(); + this._colorSelected(); + } + } +} diff --git a/frontend/web/static/src/core/color_picker/custom_color_picker/custom_color_picker.scss b/frontend/web/static/src/core/color_picker/custom_color_picker/custom_color_picker.scss new file mode 100644 index 0000000..35400dd --- /dev/null +++ b/frontend/web/static/src/core/color_picker/custom_color_picker/custom_color_picker.scss @@ -0,0 +1,51 @@ +// COLOR PICKER +.o_colorpicker_widget { + .o_color_pick_area { + height: 125px; + background-image: linear-gradient(to bottom, hsl(0, 0%, 100%) 0%, hsla(0, 0%, 100%, 0) 50%, hsla(0, 0%, 0%, 0) 50%, hsl(0, 0%, 0%) 100%), + linear-gradient(to right, hsl(0, 0%, 50%) 0%, hsla(0, 0%, 50%, 0) 100%); + cursor: crosshair; + } + .o_color_slider { + background: linear-gradient(#F00 0%, #F0F 16.66%, #00F 33.33%, #0FF 50%, #0F0 66.66%, #FF0 83.33%, #F00 100%); + } + .o_opacity_slider, .o_color_preview { + @extend %o-preview-alpha-background; + } + .o_color_slider, .o_opacity_slider { + width: 4%; + margin-right: 2%; + cursor: pointer; + } + .o_slider_pointer, .o_opacity_pointer { + @include o-position-absolute($left: -50%); + width: 200%; + height: 8px; + margin-top: -2px; + } + .o_slider_pointer, .o_opacity_pointer, .o_picker_pointer { + &:focus-visible { + outline: none; + box-shadow: + inset 0 0 0 1px rgba(white, 0.9), + 0 0 0 1px var(--bg, $white), + 0 0 0 3px var(--o-color-picker-active-color, $o-enterprise-action-color); + } + } + .o_slider_pointer, .o_opacity_pointer, .o_picker_pointer, .o_color_preview { + box-shadow: inset 0 0 0 1px rgba(white, 0.9); + border: 1px solid black; + } + .o_color_picker_inputs { + font-size: 10px; + + input { + font-family: monospace !important; // FIXME: the monospace font used in the editor has not consistent ch units on Firefox + height: 18px; + font-size: 11px; + } + .o_hex_div input { + width: 9ch; + } + } +} diff --git a/frontend/web/static/src/core/color_picker/custom_color_picker/custom_color_picker.xml b/frontend/web/static/src/core/color_picker/custom_color_picker/custom_color_picker.xml new file mode 100644 index 0000000..0637c23 --- /dev/null +++ b/frontend/web/static/src/core/color_picker/custom_color_picker/custom_color_picker.xml @@ -0,0 +1,36 @@ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ + +
+
+
+ + + diff --git a/frontend/web/static/src/core/color_picker/tabs/color_picker_custom_tab.js b/frontend/web/static/src/core/color_picker/tabs/color_picker_custom_tab.js new file mode 100644 index 0000000..e7feb58 --- /dev/null +++ b/frontend/web/static/src/core/color_picker/tabs/color_picker_custom_tab.js @@ -0,0 +1,45 @@ +import { Component } from "@odoo/owl"; +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { isColorGradient } from "@web/core/utils/colors"; +import { CustomColorPicker } from "../custom_color_picker/custom_color_picker"; + +export class ColorPickerCustomTab extends Component { + static template = "web.ColorPickerCustomTab"; + static components = { CustomColorPicker }; + static props = { + applyColor: Function, + colorPickerNavigation: Function, + onColorClick: Function, + onColorPreview: Function, + onColorPointerOver: Function, + onColorPointerOut: Function, + onFocusin: Function, + onFocusout: Function, + getUsedCustomColors: { type: Function, optional: true }, + currentColorPreview: { type: String, optional: true }, + currentCustomColor: { type: String, optional: true }, + defaultColorSet: { type: String | Boolean, optional: true }, + defaultOpacity: { type: Number, optional: true }, + grayscales: { type: Object, optional: true }, + cssVarColorPrefix: { type: String, optional: true }, + noTransparency: { type: Boolean, optional: true }, + setOnCloseCallback: { type: Function, optional: true }, + setOperationCallbacks: { type: Function, optional: true }, + "*": { optional: true }, + }; + + setup() { + this.usedCustomColors = this.props.getUsedCustomColors(); + } + + isValidCustomColor(color) { + return color && color.slice(7, 9) !== "00" && !isColorGradient(color); + } +} + +registry.category("color_picker_tabs").add("web.custom", { + id: "custom", + name: _t("Custom"), + component: ColorPickerCustomTab, +}); diff --git a/frontend/web/static/src/core/color_picker/tabs/color_picker_custom_tab.xml b/frontend/web/static/src/core/color_picker/tabs/color_picker_custom_tab.xml new file mode 100644 index 0000000..40dfa26 --- /dev/null +++ b/frontend/web/static/src/core/color_picker/tabs/color_picker_custom_tab.xml @@ -0,0 +1,45 @@ + + +
+
+ +
+ +
+ +
+
+ +
+
+
diff --git a/frontend/web/static/src/core/color_picker/tabs/color_picker_solid_tab.js b/frontend/web/static/src/core/color_picker/tabs/color_picker_solid_tab.js new file mode 100644 index 0000000..5025a7e --- /dev/null +++ b/frontend/web/static/src/core/color_picker/tabs/color_picker_solid_tab.js @@ -0,0 +1,27 @@ +import { Component } from "@odoo/owl"; +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; + +export class ColorPickerSolidTab extends Component { + static template = "web.ColorPickerSolidTab"; + static props = { + colorPickerNavigation: Function, + onColorClick: Function, + onColorPointerOver: Function, + onColorPointerOut: Function, + onFocusin: Function, + onFocusout: Function, + currentCustomColor: { type: String, optional: true }, + defaultColorSet: { type: String | Boolean, optional: true }, + cssVarColorPrefix: { type: String, optional: true }, + defaultColors: Array, + defaultThemeColorVars: Array, + "*": { optional: true }, + }; +} + +registry.category("color_picker_tabs").add("web.solid", { + id: "solid", + name: _t("Solid"), + component: ColorPickerSolidTab, +}); diff --git a/frontend/web/static/src/core/color_picker/tabs/color_picker_solid_tab.xml b/frontend/web/static/src/core/color_picker/tabs/color_picker_solid_tab.xml new file mode 100644 index 0000000..faf4feb --- /dev/null +++ b/frontend/web/static/src/core/color_picker/tabs/color_picker_solid_tab.xml @@ -0,0 +1,26 @@ + + +
+
+ +
+ +
+ + +
+
+
+
diff --git a/frontend/web/static/src/core/colorlist/colorlist.js b/frontend/web/static/src/core/colorlist/colorlist.js new file mode 100644 index 0000000..25633b3 --- /dev/null +++ b/frontend/web/static/src/core/colorlist/colorlist.js @@ -0,0 +1,62 @@ +import { _t } from "@web/core/l10n/translation"; + +import { Component, useRef, useState, useExternalListener } from "@odoo/owl"; + +export class ColorList extends Component { + static COLORS = [ + _t("No color"), + _t("Red"), + _t("Orange"), + _t("Yellow"), + _t("Cyan"), + _t("Purple"), + _t("Almond"), + _t("Teal"), + _t("Blue"), + _t("Raspberry"), + _t("Green"), + _t("Violet"), + ]; + static template = "web.ColorList"; + static defaultProps = { + forceExpanded: false, + isExpanded: false, + }; + static props = { + canToggle: { type: Boolean, optional: true }, + colors: Array, + forceExpanded: { type: Boolean, optional: true }, + isExpanded: { type: Boolean, optional: true }, + onColorSelected: Function, + selectedColor: { type: Number, optional: true }, + }; + + setup() { + this.colorlistRef = useRef("colorlist"); + this.state = useState({ isExpanded: this.props.isExpanded }); + useExternalListener(window, "click", this.onOutsideClick); + } + get colors() { + return this.constructor.COLORS; + } + onColorSelected(id) { + this.props.onColorSelected(id); + if (!this.props.forceExpanded) { + this.state.isExpanded = false; + } + } + onOutsideClick(ev) { + if (this.colorlistRef.el.contains(ev.target) || this.props.forceExpanded) { + return; + } + this.state.isExpanded = false; + } + onToggle(ev) { + if (this.props.canToggle) { + ev.preventDefault(); + ev.stopPropagation(); + this.state.isExpanded = !this.state.isExpanded; + this.colorlistRef.el.firstElementChild.focus(); + } + } +} diff --git a/frontend/web/static/src/core/colorlist/colorlist.scss b/frontend/web/static/src/core/colorlist/colorlist.scss new file mode 100644 index 0000000..536707d --- /dev/null +++ b/frontend/web/static/src/core/colorlist/colorlist.scss @@ -0,0 +1,75 @@ +@mixin o-colorlist($-entry, $-child) { + width: var(--ColorListField-width, none); + padding: var(--ColorListField-padding, 0); + box-sizing: content-box; + margin-bottom: var(--ColorListField-marginBottom, 0); + + .o_bottom_sheet_body & { + --ColorListField-padding: #{map-get($spacers, 3)} var(--BottomSheet-Entry-paddingX); + --fieldWidget-display: block; + --ColorListField-Entry-fontSize: 1.4em; + + grid-template-columns: repeat(var(--ColorListField-columns, 6), 1fr); + } + + #{$-entry} { + #{$-child} { + position: relative; + display: block; + min-width: var(--ColorListField-Entry-minWidth, o-to-rem(20px)); + aspect-ratio: var(--ColorListField-Entry-aspectRatio, 1); + border-radius: var(--ColorListField-Entry-borderRadius, 100%); + font-size: var(--ColorListField-Entry-fontSize, smaller); + overflow: hidden; + } + + &:first-child #{$-child}::after { + box-shadow: inset 0 0 0 $border-width var(--dropdown-color); + border-radius: inherit; + color: currentColor; + } + + &.active { + #{$-child}::after { + @include o-position-absolute(0, 0, 0, 0); + display: flex; + justify-content: center; + align-items: center; + color: var(--color, #{$body-color}); + font: normal normal normal 1em/1 FontAwesome; + content: "\f00c"; + } + } + + &:hover:not(.active) { + opacity: $o-opacity-muted; + } + } +} + +.o_colorlist { + @include o-colorlist("button", "&" ); + grid-template-columns: repeat(auto-fit, $o-bubble-color-size-xl); + + :not(.o_field_widget) > & { + justify-content: center; + } + + > button { + aspect-ratio: 1; + + // No Color + &.o_colorlist_item_color_0 { + background: transparent; + box-shadow: inset 0 0 0 1px $gray-500; + } + + // Set all the colors but the "no-color" one + @for $size from 2 through length($o-colors) { + &.o_colorlist_item_color_#{$size - 1} { + @include o-print-color(nth($o-colors, $size), background-color, bg-opacity); + @include o-print-color(color-contrast(nth($o-colors, $size)), color, text-opacity); + } + } + } +} diff --git a/frontend/web/static/src/core/colorlist/colorlist.xml b/frontend/web/static/src/core/colorlist/colorlist.xml new file mode 100644 index 0000000..aee8405 --- /dev/null +++ b/frontend/web/static/src/core/colorlist/colorlist.xml @@ -0,0 +1,15 @@ + + + + +
+ +
+
+ +
diff --git a/frontend/web/static/src/core/colors/colors.js b/frontend/web/static/src/core/colors/colors.js new file mode 100644 index 0000000..f2e0b2e --- /dev/null +++ b/frontend/web/static/src/core/colors/colors.js @@ -0,0 +1,217 @@ +import { clamp } from "@web/core/utils/numbers"; +/** + * Lists of colors that contrast well with each other to be used in various + * visualizations (eg. graphs/charts), both in bright and dark themes. + */ + +const COLORS_ENT_BRIGHT = ["#875A7B", "#A5D8D7", "#DCD0D9"]; +const COLORS_ENT_DARK = ["#6B3E66", "#147875", "#5A395A"]; +const COLORS_SM = [ + "#4EA7F2", // Blue + "#EA6175", // Red + "#43C5B1", // Teal + "#F4A261", // Orange + "#8481DD", // Purple + "#FFD86D", // Yellow +]; +const COLORS_MD = [ + "#4EA7F2", // Blue #1 + "#3188E6", // Blue #2 + "#43C5B1", // Teal #1 + "#00A78D", // Teal #2 + "#EA6175", // Red #1 + "#CE4257", // Red #2 + "#F4A261", // Orange #1 + "#F48935", // Orange #2 + "#8481DD", // Purple #1 + "#5752D1", // Purple #2 + "#FFD86D", // Yellow #1 + "#FFBC2C", // Yellow #2 +]; +const COLORS_LG = [ + "#4EA7F2", // Blue #1 + "#3188E6", // Blue #2 + "#056BD9", // Blue #3 + "#A76DBC", // Violet #1 + "#7F4295", // Violet #2 + "#6D2387", // Violet #3 + "#EA6175", // Red #1 + "#CE4257", // Red #2 + "#982738", // Red #3 + "#43C5B1", // Teal #1 + "#00A78D", // Teal #2 + "#0E8270", // Teal #3 + "#F4A261", // Orange #1 + "#F48935", // Orange #2 + "#BE5D10", // Orange #3 + "#8481DD", // Purple #1 + "#5752D1", // Purple #2 + "#3A3580", // Purple #3 + "#A4A8B6", // Gray #1 + "#7E8290", // Gray #2 + "#545B70", // Gray #3 + "#FFD86D", // Yellow #1 + "#FFBC2C", // Yellow #2 + "#C08A16", // Yellow #3 +]; +const COLORS_XL = [ + "#4EA7F2", // Blue #1 + "#3188E6", // Blue #2 + "#056BD9", // Blue #3 + "#155193", // Blue #4 + "#A76DBC", // Violet #1 + "#7F4295", // Violet #1 + "#6D2387", // Violet #1 + "#4F1565", // Violet #1 + "#EA6175", // Red #1 + "#CE4257", // Red #2 + "#982738", // Red #3 + "#791B29", // Red #4 + "#43C5B1", // Teal #1 + "#00A78D", // Teal #2 + "#0E8270", // Teal #3 + "#105F53", // Teal #4 + "#F4A261", // Orange #1 + "#F48935", // Orange #2 + "#BE5D10", // Orange #3 + "#7D380D", // Orange #4 + "#8481DD", // Purple #1 + "#5752D1", // Purple #2 + "#3A3580", // Purple #3 + "#26235F", // Purple #4 + "#A4A8B6", // Grey #1 + "#7E8290", // Grey #2 + "#545B70", // Grey #3 + "#3F4250", // Grey #4 + "#FFD86D", // Yellow #1 + "#FFBC2C", // Yellow #2 + "#C08A16", // Yellow #3 + "#936A12", // Yellow #4 +]; + +/** + * @param {string} colorScheme + * @param {string} paletteName + * @returns {array} + */ +export function getColors(colorScheme, paletteName) { + switch (paletteName) { + case "odoo": + return colorScheme === "dark" ? COLORS_ENT_DARK : COLORS_ENT_BRIGHT; + case "sm": + return COLORS_SM; + case "md": + return COLORS_MD; + case "lg": + return COLORS_LG; + default: + return COLORS_XL; + } +} + +/** + * @param {number} index + * @param {string} colorScheme + * @returns {string} + */ +export function getColor(index, colorScheme, paletteSizeOrName) { + let paletteName; + if (paletteSizeOrName === "odoo") { + paletteName = "odoo"; + } else if (paletteSizeOrName <= 6 || paletteSizeOrName === "sm") { + paletteName = "sm"; + } else if (paletteSizeOrName <= 12 || paletteSizeOrName === "md") { + paletteName = "md"; + } else if (paletteSizeOrName <= 24 || paletteSizeOrName === "lg") { + paletteName = "lg"; + } else { + paletteName = "xl"; + } + const colors = getColors(colorScheme, paletteName); + return colors[index % colors.length]; +} + +export const DEFAULT_BG = "#d3d3d3"; + +export function getBorderWhite(colorScheme) { + return colorScheme === "dark" ? "rgba(38, 42, 54, .2)" : "rgba(249,250,251, .2)"; +} + +const RGB_REGEX = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i; + +/** + * @param {string} hex + * @param {number} opacity + * @returns {string} + */ +export function hexToRGBA(hex, opacity) { + const rgb = RGB_REGEX.exec(hex) + .slice(1, 4) + .map((n) => parseInt(n, 16)) + .join(","); + return `rgba(${rgb},${opacity})`; +} + +/** + * Used to return custom colors depending on the color scheme + * @param {string} colorScheme + * @param {string} brightModeColor + * @param {string} darkModeColor + * @returns {string|Number|Boolean} + */ + +export function getCustomColor(colorScheme, brightModeColor, darkModeColor) { + if (darkModeColor === undefined) { + return brightModeColor; + } else { + return colorScheme === "dark" ? darkModeColor : brightModeColor; + } +} + +/** + * Used to lighten a color + * @param {string} color + * @param {number} factor + * @returns {string} + */ +export function lightenColor(color, factor) { + factor = clamp(factor, 0, 1); + + let r = parseInt(color.substring(1, 3), 16); + let g = parseInt(color.substring(3, 5), 16); + let b = parseInt(color.substring(5, 7), 16); + + r = Math.round(r + (255 - r) * factor); + g = Math.round(g + (255 - g) * factor); + b = Math.round(b + (255 - b) * factor); + + r = r.toString(16).padStart(2, "0"); + g = g.toString(16).padStart(2, "0"); + b = b.toString(16).padStart(2, "0"); + + return `#${r}${g}${b}`; +} + +/** + * Used to darken a color + * @param {string} color + * @param {number} factor + * @returns {string} + */ +export function darkenColor(color, factor) { + factor = clamp(factor, 0, 1); + + let r = parseInt(color.substring(1, 3), 16); + let g = parseInt(color.substring(3, 5), 16); + let b = parseInt(color.substring(5, 7), 16); + + r = Math.round(r * (1 - factor)); + g = Math.round(g * (1 - factor)); + b = Math.round(b * (1 - factor)); + + r = r.toString(16).padStart(2, "0"); + g = g.toString(16).padStart(2, "0"); + b = b.toString(16).padStart(2, "0"); + + return `#${r}${g}${b}`; +} diff --git a/frontend/web/static/src/core/commands/command_category.js b/frontend/web/static/src/core/commands/command_category.js new file mode 100644 index 0000000..d40ec49 --- /dev/null +++ b/frontend/web/static/src/core/commands/command_category.js @@ -0,0 +1,11 @@ +import { registry } from "@web/core/registry"; + +const commandCategoryRegistry = registry.category("command_categories"); +commandCategoryRegistry + .add("app", {}, { sequence: 10 }) + .add("smart_action", {}, { sequence: 15 }) + .add("actions", {}, { sequence: 30 }) + .add("default", {}, { sequence: 50 }) + .add("view_switcher", {}, { sequence: 100 }) + .add("debug", {}, { sequence: 110 }) + .add("disabled", {}); diff --git a/frontend/web/static/src/core/commands/command_hook.js b/frontend/web/static/src/core/commands/command_hook.js new file mode 100644 index 0000000..452e88c --- /dev/null +++ b/frontend/web/static/src/core/commands/command_hook.js @@ -0,0 +1,23 @@ +import { useService } from "@web/core/utils/hooks"; + +import { useEffect } from "@odoo/owl"; + +/** + * @typedef {import("./command_service").CommandOptions} CommandOptions + */ + +/** + * This hook will subscribe/unsubscribe the given subscription + * when the caller component will mount/unmount. + * + * @param {string} name + * @param {()=>(void | import("@web/core/commands/command_palette").CommandPaletteConfig)} action + * @param {CommandOptions} [options] + */ +export function useCommand(name, action, options = {}) { + const commandService = useService("command"); + useEffect( + () => commandService.add(name, action, options), + () => [] + ); +} diff --git a/frontend/web/static/src/core/commands/command_items.xml b/frontend/web/static/src/core/commands/command_items.xml new file mode 100644 index 0000000..6a100df --- /dev/null +++ b/frontend/web/static/src/core/commands/command_items.xml @@ -0,0 +1,35 @@ + + + + + TIP — search for + + , + and + + + + + + + + +
+ + +
+
+ + +
+ + + + + + + + +
+
+ +
diff --git a/frontend/web/static/src/core/commands/command_palette.js b/frontend/web/static/src/core/commands/command_palette.js new file mode 100644 index 0000000..c4a1a60 --- /dev/null +++ b/frontend/web/static/src/core/commands/command_palette.js @@ -0,0 +1,388 @@ +import { Dialog } from "@web/core/dialog/dialog"; +import { useHotkey } from "@web/core/hotkeys/hotkey_hook"; +import { _t } from "@web/core/l10n/translation"; +import { KeepLast, Race } from "@web/core/utils/concurrency"; +import { useAutofocus, useService } from "@web/core/utils/hooks"; +import { scrollTo } from "@web/core/utils/scrolling"; +import { fuzzyLookup } from "@web/core/utils/search"; +import { debounce } from "@web/core/utils/timing"; +import { isMacOS, isMobileOS } from "@web/core/browser/feature_detection"; +import { highlightText } from "@web/core/utils/html"; + +import { + Component, + onWillStart, + onWillDestroy, + EventBus, + useRef, + useState, + markRaw, + useExternalListener, +} from "@odoo/owl"; + +const DEFAULT_PLACEHOLDER = _t("Search..."); +const DEFAULT_EMPTY_MESSAGE = _t("No result found"); +const FUZZY_NAMESPACES = ["default"]; + +/** + * @typedef {import("./command_service").Command} Command + */ + +/** + * @typedef {Command & { + * Component?: Component; + * props?: object; + * }} CommandItem + */ + +/** + * @typedef {{ + * namespace?: string; + * provide: ()=>CommandItem[]; + * }} Provider + */ + +/** + * @typedef {{ + * categories: string[]; + * debounceDelay: number; + * emptyMessage: string; + * placeholder: string; + * }} NamespaceConfig + */ + +/** + * @typedef {{ + * configByNamespace?: {[namespace: string]: NamespaceConfig}; + * FooterComponent?: Component; + * providers: Provider[]; + * searchValue?: string; + * }} CommandPaletteConfig + */ + +/** + * Util used to filter commands that are within category. + * Note: for the default category, also get all commands having invalid category. + * + * @param {string} categoryName the category key + * @param {string[]} categories + * @returns an array filter predicate + */ +function commandsWithinCategory(categoryName, categories) { + return (cmd) => { + const inCurrentCategory = categoryName === cmd.category; + const fallbackCategory = categoryName === "default" && !categories.includes(cmd.category); + return inCurrentCategory || fallbackCategory; + }; +} + +export class DefaultCommandItem extends Component { + static template = "web.DefaultCommandItem"; + static props = { + slots: { type: Object, optional: true }, + // Props send by the command palette: + hotkey: { type: String, optional: true }, + hotkeyOptions: { type: String, optional: true }, + name: { type: String, optional: true }, + searchValue: { type: String, optional: true }, + executeCommand: { type: Function, optional: true }, + }; +} + +export class CommandPalette extends Component { + static template = "web.CommandPalette"; + static components = { Dialog }; + static lastSessionId = 0; + static props = { + bus: { type: EventBus, optional: true }, + close: Function, + config: Object, + closeMe: { type: Function, optional: true }, + }; + + setup() { + if (this.props.bus) { + const setConfig = ({ detail }) => this.setCommandPaletteConfig(detail); + this.props.bus.addEventListener(`SET-CONFIG`, setConfig); + onWillDestroy(() => this.props.bus.removeEventListener(`SET-CONFIG`, setConfig)); + } + + this.keyId = 1; + this.race = new Race(); + this.keepLast = new KeepLast(); + this._sessionId = CommandPalette.lastSessionId++; + this.DefaultCommandItem = DefaultCommandItem; + this.activeElement = useService("ui").activeElement; + this.inputRef = useAutofocus(); + + useHotkey("Enter", () => this.executeSelectedCommand(), { bypassEditableProtection: true }); + useHotkey("Control+Enter", () => this.executeSelectedCommand(true), { + bypassEditableProtection: true, + }); + useHotkey("ArrowUp", () => this.selectCommandAndScrollTo("PREV"), { + bypassEditableProtection: true, + allowRepeat: true, + }); + useHotkey("ArrowDown", () => this.selectCommandAndScrollTo("NEXT"), { + bypassEditableProtection: true, + allowRepeat: true, + }); + useExternalListener(window, "mousedown", this.onWindowMouseDown); + + /** + * @type {{ commands: CommandItem[], + * emptyMessage: string, + * FooterComponent: Component, + * namespace: string, + * placeholder: string, + * searchValue: string, + * selectedCommand: CommandItem }} + */ + this.state = useState({}); + + this.root = useRef("root"); + this.listboxRef = useRef("listbox"); + + onWillStart(() => this.setCommandPaletteConfig(this.props.config)); + } + + get commandsByCategory() { + const categories = []; + for (const category of this.categoryKeys) { + const commands = this.state.commands.filter( + commandsWithinCategory(category, this.categoryKeys) + ); + if (commands.length) { + categories.push({ + commands, + name: this.categoryNames[category], + keyId: category, + }); + } + } + return categories; + } + + /** + * Apply the new config to the command pallet + * @param {CommandPaletteConfig} config + */ + async setCommandPaletteConfig(config) { + this.configByNamespace = config.configByNamespace || {}; + this.state.FooterComponent = config.FooterComponent; + + this.providersByNamespace = { default: [] }; + for (const provider of config.providers) { + const namespace = provider.namespace || "default"; + if (namespace in this.providersByNamespace) { + this.providersByNamespace[namespace].push(provider); + } else { + this.providersByNamespace[namespace] = [provider]; + } + } + + const { namespace, searchValue } = this.processSearchValue(config.searchValue || ""); + this.switchNamespace(namespace); + this.state.searchValue = searchValue; + await this.race.add(this.search(searchValue)); + } + + /** + * Modifies the commands to be displayed according to the namespace and the options. + * Selects the first command in the new list. + * @param {string} namespace + * @param {object} options + */ + async setCommands(namespace, options = {}) { + this.categoryKeys = ["default"]; + this.categoryNames = {}; + const proms = this.providersByNamespace[namespace].map((provider) => { + const { provide } = provider; + const result = provide(this.env, options); + return result; + }); + let commands = (await this.keepLast.add(Promise.all(proms))).flat(); + const namespaceConfig = this.configByNamespace[namespace] || {}; + if (options.searchValue && FUZZY_NAMESPACES.includes(namespace)) { + commands = fuzzyLookup(options.searchValue, commands, (c) => c.name); + } else { + // we have to sort the commands by category to avoid navigation issues with the arrows + if (namespaceConfig.categories) { + let commandsSorted = []; + this.categoryKeys = namespaceConfig.categories; + this.categoryNames = namespaceConfig.categoryNames || {}; + if (!this.categoryKeys.includes("default")) { + this.categoryKeys.push("default"); + } + for (const category of this.categoryKeys) { + commandsSorted = commandsSorted.concat( + commands.filter(commandsWithinCategory(category, this.categoryKeys)) + ); + } + commands = commandsSorted; + } + } + + this.state.commands = markRaw( + commands.slice(0, 100).map((command) => ({ + ...command, + keyId: this.keyId++, + text: highlightText(options.searchValue, command.name, "fw-bolder text-primary"), + })) + ); + this.selectCommand(this.state.commands.length ? 0 : -1); + this.mouseSelectionActive = false; + this.state.emptyMessage = ( + namespaceConfig.emptyMessage || DEFAULT_EMPTY_MESSAGE + ).toString(); + } + + selectCommand(index) { + if (index === -1 || index >= this.state.commands.length) { + this.state.selectedCommand = null; + return; + } + this.state.selectedCommand = markRaw(this.state.commands[index]); + } + + selectCommandAndScrollTo(type) { + // In case the mouse is on the palette command, it avoids the selection + // of a command caused by a scroll. + this.mouseSelectionActive = false; + const index = this.state.commands.indexOf(this.state.selectedCommand); + if (index === -1) { + return; + } + let nextIndex; + if (type === "NEXT") { + nextIndex = index < this.state.commands.length - 1 ? index + 1 : 0; + } else if (type === "PREV") { + nextIndex = index > 0 ? index - 1 : this.state.commands.length - 1; + } + this.selectCommand(nextIndex); + + const command = this.listboxRef.el.querySelector(`#o_command_${nextIndex}`); + scrollTo(command, { scrollable: this.listboxRef.el }); + } + + onCommandClicked(event, index) { + event.preventDefault(); // Prevent redirect for commands with href + this.selectCommand(index); + const ctrlKey = isMacOS() ? event.metaKey : event.ctrlKey; + this.executeSelectedCommand(ctrlKey); + } + + /** + * Execute the action related to the order. + * If this action returns a config, then we will use it in the command palette, + * otherwise we close the command palette. + * @param {CommandItem} command + */ + async executeCommand(command) { + const config = await command.action(); + if (config) { + this.setCommandPaletteConfig(config); + } else { + this.props.close(); + } + } + + async executeSelectedCommand(ctrlKey) { + await this.searchValuePromise; + const selectedCommand = this.state.selectedCommand; + if (selectedCommand) { + if (!ctrlKey) { + this.executeCommand(selectedCommand); + } else if (selectedCommand.href) { + window.open(selectedCommand.href, "_blank"); + } + } + } + + onCommandMouseEnter(index) { + if (this.mouseSelectionActive) { + this.selectCommand(index); + } else { + this.mouseSelectionActive = true; + } + } + + async search(searchValue) { + this.state.isLoading = true; + try { + await this.setCommands(this.state.namespace, { + searchValue, + activeElement: this.activeElement, + sessionId: this._sessionId, + }); + } finally { + this.state.isLoading = false; + } + if (this.inputRef.el) { + this.inputRef.el.focus(); + } + } + + debounceSearch(value) { + const { namespace, searchValue } = this.processSearchValue(value); + if (namespace !== "default" && this.state.namespace !== namespace) { + this.switchNamespace(namespace); + } + this.state.searchValue = searchValue; + this.searchValuePromise = this.lastDebounceSearch(searchValue).catch(() => { + this.searchValuePromise = null; + }); + } + + onSearchInput(ev) { + this.debounceSearch(ev.target.value); + } + + onKeyDown(ev) { + if (ev.key.toLowerCase() === "backspace" && !ev.target.value.length && !ev.repeat) { + this.switchNamespace("default"); + this.state.searchValue = ""; + this.searchValuePromise = this.lastDebounceSearch("").catch(() => { + this.searchValuePromise = null; + }); + } + } + + /** + * Close the palette on outside click. + */ + onWindowMouseDown(ev) { + if (!this.root.el.contains(ev.target)) { + this.props.close(); + } + } + + switchNamespace(namespace) { + if (this.lastDebounceSearch) { + this.lastDebounceSearch.cancel(); + } + const namespaceConfig = this.configByNamespace[namespace] || {}; + this.lastDebounceSearch = debounce( + (value) => this.search(value), + namespaceConfig.debounceDelay || 0 + ); + this.state.namespace = namespace; + this.state.placeholder = namespaceConfig.placeholder || DEFAULT_PLACEHOLDER.toString(); + } + + processSearchValue(searchValue) { + let namespace = "default"; + if (searchValue.length && this.providersByNamespace[searchValue[0]]) { + namespace = searchValue[0]; + searchValue = searchValue.slice(1); + } + return { namespace, searchValue }; + } + + get isMacOS() { + return isMacOS(); + } + get isMobileOS() { + return isMobileOS(); + } +} diff --git a/frontend/web/static/src/core/commands/command_palette.scss b/frontend/web/static/src/core/commands/command_palette.scss new file mode 100644 index 0000000..4736e80 --- /dev/null +++ b/frontend/web/static/src/core/commands/command_palette.scss @@ -0,0 +1,53 @@ +.o_command_palette { + $-app-icon-size: 1.8rem; + top: 120px; + position: absolute; + + > .modal-body { + padding: 0; + } + + &_listbox { + max-height: 50vh; + + .o_command { + &.focused { + background: rgba($o-component-active-bg, .65); + } + + &_hotkey { + align-items: center; + justify-content: space-between; + background-color: inherit; + padding: 0.5rem 1.3em; + display: flex; + } + a { + text-decoration: none; + color: inherit; + } + } + + } + + .o_favorite { + color: $o-main-favorite-color; + } + + .o_app_icon { + height: $-app-icon-size; + width: $-app-icon-size; + } + .o_command{ + cursor: pointer; + .text-ellipsis { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + .o_command_focus { + white-space: nowrap; + opacity: 0.9; + } + } +} diff --git a/frontend/web/static/src/core/commands/command_palette.xml b/frontend/web/static/src/core/commands/command_palette.xml new file mode 100644 index 0000000..6d5f1f0 --- /dev/null +++ b/frontend/web/static/src/core/commands/command_palette.xml @@ -0,0 +1,61 @@ + + + + + +
+ + +
+
+ + + +
+
+
+ + +
+
+
+ +
diff --git a/frontend/web/static/src/core/commands/command_service.js b/frontend/web/static/src/core/commands/command_service.js new file mode 100644 index 0000000..aa87071 --- /dev/null +++ b/frontend/web/static/src/core/commands/command_service.js @@ -0,0 +1,261 @@ +import { registry } from "@web/core/registry"; +import { CommandPalette } from "./command_palette"; + +import { Component, EventBus } from "@odoo/owl"; + +/** + * @typedef {import("./command_palette").CommandPaletteConfig} CommandPaletteConfig + * @typedef {import("../hotkeys/hotkey_service").HotkeyOptions} HotkeyOptions + */ + +/** + * @typedef {{ + * name: string; + * action: ()=>(void | CommandPaletteConfig); + * category?: string; + * href?: string; + * className?: string; + * }} Command + */ + +/** + * @typedef {{ + * category?: string; + * isAvailable?: ()=>(boolean); + * global?: boolean; + * hotkey?: string; + * hotkeyOptions?: HotkeyOptions + * }} CommandOptions + */ + +/** + * @typedef {Command & CommandOptions & { + * removeHotkey?: ()=>void; + * }} CommandRegistration + */ + +const commandCategoryRegistry = registry.category("command_categories"); +const commandProviderRegistry = registry.category("command_provider"); +const commandSetupRegistry = registry.category("command_setup"); + +class DefaultFooter extends Component { + static template = "web.DefaultFooter"; + static props = { + switchNamespace: { type: Function }, + }; + setup() { + this.elements = commandSetupRegistry + .getEntries() + .map((el) => ({ namespace: el[0], name: el[1].name })) + .filter((el) => el.name); + } + + onClick(namespace) { + this.props.switchNamespace(namespace); + } +} + +export const commandService = { + dependencies: ["dialog", "hotkey", "ui"], + start(env, { dialog, hotkey: hotkeyService, ui }) { + /** @type {Map} */ + const registeredCommands = new Map(); + let nextToken = 0; + let isPaletteOpened = false; + const bus = new EventBus(); + + hotkeyService.add("control+k", openMainPalette, { + bypassEditableProtection: true, + global: true, + }); + + /** + * @param {CommandPaletteConfig} config command palette config merged with default config + * @param {Function} onClose called when the command palette is closed + * @returns the actual command palette config if the command palette is already open + */ + function openMainPalette(config = {}, onClose) { + const configByNamespace = {}; + for (const provider of commandProviderRegistry.getAll()) { + const namespace = provider.namespace || "default"; + if (!configByNamespace[namespace]) { + configByNamespace[namespace] = { + categories: [], + categoryNames: {}, + }; + } + } + + for (const [category, el] of commandCategoryRegistry.getEntries()) { + const namespace = el.namespace || "default"; + const name = el.name; + if (namespace in configByNamespace) { + configByNamespace[namespace].categories.push(category); + configByNamespace[namespace].categoryNames[category] = name; + } + } + + for (const [ + namespace, + { emptyMessage, debounceDelay, placeholder }, + ] of commandSetupRegistry.getEntries()) { + if (namespace in configByNamespace) { + if (emptyMessage) { + configByNamespace[namespace].emptyMessage = emptyMessage; + } + if (debounceDelay !== undefined) { + configByNamespace[namespace].debounceDelay = debounceDelay; + } + if (placeholder) { + configByNamespace[namespace].placeholder = placeholder; + } + } + } + + config = Object.assign( + { + configByNamespace, + FooterComponent: DefaultFooter, + providers: commandProviderRegistry.getAll(), + }, + config + ); + return openPalette(config, onClose); + } + + /** + * @param {CommandPaletteConfig} config + * @param {Function} onClose called when the command palette is closed + */ + function openPalette(config, onClose) { + if (isPaletteOpened) { + bus.trigger("SET-CONFIG", config); + return; + } + + // Open Command Palette dialog + isPaletteOpened = true; + dialog.add( + CommandPalette, + { + config, + bus, + }, + { + onClose: () => { + isPaletteOpened = false; + if (onClose) { + onClose(); + } + }, + } + ); + } + + /** + * @param {Command} command + * @param {CommandOptions} options + * @returns {number} token + */ + function registerCommand(command, options) { + if (!command.name || !command.action || typeof command.action !== "function") { + throw new Error("A Command must have a name and an action function."); + } + const registration = Object.assign({}, command, options); + if (registration.identifier) { + const commandsArray = Array.from(registeredCommands.values()); + const sameName = commandsArray.find((com) => com.name === registration.name); + if (sameName) { + if (registration.identifier !== sameName.identifier) { + registration.name += ` (${registration.identifier})`; + sameName.name += ` (${sameName.identifier})`; + } + } else { + const sameFullName = commandsArray.find( + (com) => com.name === registration.name + `(${registration.identifier})` + ); + if (sameFullName) { + registration.name += ` (${registration.identifier})`; + } + } + } + if (registration.hotkey) { + const action = async () => { + const commandService = env.services.command; + const config = await command.action(); + if (!isPaletteOpened && config) { + commandService.openPalette(config); + } + }; + registration.removeHotkey = hotkeyService.add(registration.hotkey, action, { + ...options.hotkeyOptions, + global: registration.global, + isAvailable: (...args) => { + let available = true; + if (registration.isAvailable) { + available = registration.isAvailable(...args); + } + if (available && options.hotkeyOptions?.isAvailable) { + available = options.hotkeyOptions?.isAvailable(...args); + } + return available; + }, + }); + } + + const token = nextToken++; + registeredCommands.set(token, registration); + if (!options.activeElement) { + // Due to the way elements are mounted in the DOM by Owl (bottom-to-top), + // we need to wait the next micro task tick to set the context activate + // element of the subscription. + Promise.resolve().then(() => { + registration.activeElement = ui.activeElement; + }); + } + + return token; + } + + /** + * Unsubscribes the token corresponding subscription. + * + * @param {number} token + */ + function unregisterCommand(token) { + const cmd = registeredCommands.get(token); + if (cmd && cmd.removeHotkey) { + cmd.removeHotkey(); + } + registeredCommands.delete(token); + } + + return { + /** + * @param {string} name + * @param {()=>(void | CommandPaletteConfig)} action + * @param {CommandOptions} [options] + * @returns {() => void} + */ + add(name, action, options = {}) { + const token = registerCommand({ name, action }, options); + return () => { + unregisterCommand(token); + }; + }, + /** + * @param {HTMLElement} activeElement + * @returns {Command[]} + */ + getCommands(activeElement) { + return [...registeredCommands.values()].filter( + (command) => command.activeElement === activeElement || command.global + ); + }, + openMainPalette, + openPalette, + }; + }, +}; + +registry.category("services").add("command", commandService); diff --git a/frontend/web/static/src/core/commands/default_providers.js b/frontend/web/static/src/core/commands/default_providers.js new file mode 100644 index 0000000..8deabfc --- /dev/null +++ b/frontend/web/static/src/core/commands/default_providers.js @@ -0,0 +1,109 @@ +import { isMacOS } from "@web/core/browser/feature_detection"; +import { useHotkey } from "@web/core/hotkeys/hotkey_hook"; +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; +import { capitalize } from "@web/core/utils/strings"; +import { getVisibleElements } from "@web/core/utils/ui"; +import { DefaultCommandItem } from "./command_palette"; + +import { Component } from "@odoo/owl"; + +const commandSetupRegistry = registry.category("command_setup"); +commandSetupRegistry.add("default", { + emptyMessage: _t("No command found"), + placeholder: _t("Search for a command..."), +}); + +export class HotkeyCommandItem extends Component { + static template = "web.HotkeyCommandItem"; + static props = ["hotkey", "hotkeyOptions?", "name?", "searchValue?", "executeCommand", "slots"]; + setup() { + useHotkey(this.props.hotkey, this.props.executeCommand); + } + + getKeysToPress(command) { + const { hotkey } = command; + let result = hotkey.split("+"); + if (isMacOS()) { + result = result + .map((x) => x.replace("control", "command")) + .map((x) => x.replace("alt", "control")); + } + return result.map((key) => key.toUpperCase()); + } +} + +const commandCategoryRegistry = registry.category("command_categories"); +const commandProviderRegistry = registry.category("command_provider"); +commandProviderRegistry.add("command", { + provide: (env, options = {}) => { + const commands = env.services.command + .getCommands(options.activeElement) + .map((cmd) => { + cmd.category = commandCategoryRegistry.contains(cmd.category) + ? cmd.category + : "default"; + return cmd; + }) + .filter((command) => command.isAvailable === undefined || command.isAvailable()); + // Filter out same category dupplicate commands + const uniqueCommands = commands.filter((obj, index) => { + return ( + index === + commands.findIndex((o) => obj.name === o.name && obj.category === o.category) + ); + }); + return uniqueCommands.map((command) => ({ + Component: command.hotkey ? HotkeyCommandItem : DefaultCommandItem, + action: command.action, + category: command.category, + name: command.name, + props: { + hotkey: command.hotkey, + hotkeyOptions: command.hotkeyOptions, + }, + })); + }, +}); + +commandProviderRegistry.add("data-hotkeys", { + provide: (env, options = {}) => { + const commands = []; + const overlayModifier = registry.category("services").get("hotkey").overlayModifier; + // Also retrieve all hotkeyables elements + for (const el of getVisibleElements( + options.activeElement, + "[data-hotkey]:not(:disabled)" + )) { + const closest = el.closest("[data-command-category]"); + const category = closest ? closest.dataset.commandCategory : "default"; + if (category === "disabled") { + continue; + } + + const description = + el.title || + el.dataset.bsOriginalTitle || // LEGACY: bootstrap moves title to data-bs-original-title + el.dataset.tooltip || + el.placeholder || + (el.innerText && + `${el.innerText.slice(0, 50)}${el.innerText.length > 50 ? "..." : ""}`) || + _t("no description provided"); + + commands.push({ + Component: HotkeyCommandItem, + action: () => { + // AAB: not sure it is enough, we might need to trigger all events that occur when you actually click + el.focus(); + el.click(); + }, + category, + name: capitalize(description.trim().toLowerCase()), + props: { + hotkey: `${overlayModifier}+${el.dataset.hotkey}`, + }, + }); + } + return commands; + }, +}); diff --git a/frontend/web/static/src/core/confirmation_dialog/confirmation_dialog.js b/frontend/web/static/src/core/confirmation_dialog/confirmation_dialog.js new file mode 100644 index 0000000..2c9b133 --- /dev/null +++ b/frontend/web/static/src/core/confirmation_dialog/confirmation_dialog.js @@ -0,0 +1,103 @@ +import { Dialog } from "../dialog/dialog"; +import { _t } from "@web/core/l10n/translation"; +import { useChildRef } from "@web/core/utils/hooks"; + +import { Component } from "@odoo/owl"; + +export const deleteConfirmationMessage = _t( + `Ready to make your record disappear into thin air? Are you sure? +It will be gone forever! + +Think twice before you click that 'Delete' button!` +); + +export class ConfirmationDialog extends Component { + static template = "web.ConfirmationDialog"; + static components = { Dialog }; + static props = { + close: Function, + title: { + validate: (m) => { + return ( + typeof m === "string" || + (typeof m === "object" && typeof m.toString === "function") + ); + }, + optional: true, + }, + body: { type: String, optional: true }, + confirm: { type: Function, optional: true }, + confirmLabel: { type: String, optional: true }, + confirmClass: { type: String, optional: true }, + cancel: { type: Function, optional: true }, + cancelLabel: { type: String, optional: true }, + dismiss: { type: Function, optional: true }, + }; + static defaultProps = { + confirmLabel: _t("Ok"), + cancelLabel: _t("Cancel"), + confirmClass: "btn-primary", + title: _t("Confirmation"), + }; + + setup() { + this.env.dialogData.dismiss = () => this._dismiss(); + this.modalRef = useChildRef(); + this.isProcess = false; + } + + async _cancel() { + return this.execButton(this.props.cancel); + } + + async _confirm() { + return this.execButton(this.props.confirm); + } + + async _dismiss() { + return this.execButton(this.props.dismiss || this.props.cancel); + } + + setButtonsDisabled(disabled) { + this.isProcess = disabled; + if (!this.modalRef.el) { + return; // safety belt for stable versions + } + for (const button of [...this.modalRef.el.querySelectorAll(".modal-footer button")]) { + button.disabled = disabled; + } + } + + async execButton(callback) { + if (this.isProcess) { + return; + } + this.setButtonsDisabled(true); + if (callback) { + let shouldClose; + try { + shouldClose = await callback(); + } catch (e) { + this.props.close(); + throw e; + } + if (shouldClose === false) { + this.setButtonsDisabled(false); + return; + } + } + this.props.close(); + } +} + +export class AlertDialog extends ConfirmationDialog { + static template = "web.AlertDialog"; + static props = { + ...ConfirmationDialog.props, + contentClass: { type: String, optional: true }, + }; + static defaultProps = { + ...ConfirmationDialog.defaultProps, + title: _t("Alert"), + }; +} diff --git a/frontend/web/static/src/core/confirmation_dialog/confirmation_dialog.xml b/frontend/web/static/src/core/confirmation_dialog/confirmation_dialog.xml new file mode 100644 index 0000000..5ac84b5 --- /dev/null +++ b/frontend/web/static/src/core/confirmation_dialog/confirmation_dialog.xml @@ -0,0 +1,24 @@ + + + + + +

+ +

+
+ + + +

+ +

+
+ +
diff --git a/frontend/web/static/src/core/context.js b/frontend/web/static/src/core/context.js new file mode 100644 index 0000000..bc9ba51 --- /dev/null +++ b/frontend/web/static/src/core/context.js @@ -0,0 +1,85 @@ +import { evaluateExpr, parseExpr } from "./py_js/py"; +import { BUILTINS } from "./py_js/py_builtin"; +import { evaluate } from "./py_js/py_interpreter"; + +/** + * @typedef {{ + * lang?: string; + * tz?: string; + * uid?: number | false; + * [key: string]: any; + * }} Context + * @typedef {Context | string | undefined} ContextDescription + */ + +/** + * Create an evaluated context from an arbitrary list of context representations. + * The evaluated context in construction is used along the way to evaluate further parts. + * + * @param {ContextDescription[]} contexts + * @param {Context} [initialEvaluationContext] optional evaluation context to start from. + * @returns {Context} + */ +export function makeContext(contexts, initialEvaluationContext) { + const evaluationContext = Object.assign({}, initialEvaluationContext); + const context = {}; + for (let ctx of contexts) { + if (ctx !== "") { + ctx = typeof ctx === "string" ? evaluateExpr(ctx, evaluationContext) : ctx; + Object.assign(context, ctx); + Object.assign(evaluationContext, context); // is this behavior really wanted ? + } + } + return context; +} + +/** + * Extract a partial list of variable names found in the AST. + * Note that it is not complete. It is used as an heuristic to avoid + * evaluating expressions that we know for sure will fail. + * + * @param {AST} ast + * @returns string[] + */ +function getPartialNames(ast) { + if (ast.type === 5) { + return [ast.value]; + } + if (ast.type === 6) { + return getPartialNames(ast.right); + } + if (ast.type === 14 || ast.type === 7) { + return getPartialNames(ast.left).concat(getPartialNames(ast.right)); + } + if (ast.type === 15) { + return getPartialNames(ast.obj); + } + return []; +} + +/** + * Allow to evaluate a context with an incomplete evaluation context. The evaluated context only + * contains keys whose values are static or can be evaluated with the given evaluation context. + * + * @param {string} context + * @param {Context} [evaluationContext={}] + * @returns {Context} + */ +export function evalPartialContext(_context, evaluationContext = {}) { + const ast = parseExpr(_context); + const context = {}; + for (const key in ast.value) { + const value = ast.value[key]; + if ( + getPartialNames(value).some((name) => !(name in evaluationContext || name in BUILTINS)) + ) { + continue; + } + try { + context[key] = evaluate(value, evaluationContext); + } catch { + // ignore this key as we can't evaluate its value + } + } + return context; +} diff --git a/frontend/web/static/src/core/copy_button/copy_button.js b/frontend/web/static/src/core/copy_button/copy_button.js new file mode 100644 index 0000000..bce509f --- /dev/null +++ b/frontend/web/static/src/core/copy_button/copy_button.js @@ -0,0 +1,48 @@ +import { browser } from "@web/core/browser/browser"; +import { Tooltip } from "@web/core/tooltip/tooltip"; +import { usePopover } from "@web/core/popover/popover_hook"; +import { Component, useRef } from "@odoo/owl"; + +export class CopyButton extends Component { + static template = "web.CopyButton"; + static props = { + className: { type: String, optional: true }, + copyText: { type: String, optional: true }, + disabled: { type: Boolean, optional: true }, + successText: { type: String, optional: true }, + icon: { type: String, optional: true }, + content: { type: [String, Object, Function], optional: true }, + }; + + setup() { + this.button = useRef("button"); + this.popover = usePopover(Tooltip); + } + + showTooltip() { + this.popover.open(this.button.el, { tooltip: this.props.successText }); + browser.setTimeout(this.popover.close, 800); + } + + async onClick() { + let write, content; + if (typeof this.props.content === "function") { + content = this.props.content(); + } else { + content = this.props.content; + } + // any kind of content can be copied into the clipboard using + // the appropriate native methods + if (typeof content === "string" || content instanceof String) { + write = (value) => browser.navigator.clipboard.writeText(value); + } else { + write = (value) => browser.navigator.clipboard.write(value); + } + try { + await write(content); + } catch (error) { + return browser.console.warn(error); + } + this.showTooltip(); + } +} diff --git a/frontend/web/static/src/core/copy_button/copy_button.xml b/frontend/web/static/src/core/copy_button/copy_button.xml new file mode 100644 index 0000000..1848bd5 --- /dev/null +++ b/frontend/web/static/src/core/copy_button/copy_button.xml @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/frontend/web/static/src/core/currency.js b/frontend/web/static/src/core/currency.js new file mode 100644 index 0000000..07cacf9 --- /dev/null +++ b/frontend/web/static/src/core/currency.js @@ -0,0 +1,98 @@ +import { reactive } from "@odoo/owl"; +import { rpc } from "@web/core/network/rpc"; +import { user } from "@web/core/user"; +import { formatFloat, humanNumber } from "@web/core/utils/numbers"; +import { nbsp } from "@web/core/utils/strings"; +import { session } from "@web/session"; + +export const currencies = session.currencies || {}; +// to make sure code is reading currencies from here +delete session.currencies; + +export function getCurrency(id) { + return currencies[id]; +} + +export async function getCurrencyRates() { + const rates = reactive({}); + + function recordsToRates(records) { + return Object.fromEntries(records.map((r) => [r.id, r.inverse_rate])); + } + + const model = "res.currency"; + const method = "read"; + const url = `/web/dataset/call_kw/${model}/${method}`; + const context = { + ...user.context, + to_currency: user.activeCompany.currency_id, + }; + const params = { + model, + method, + args: [Object.keys(currencies).map(Number), ["inverse_rate"]], + kwargs: { context }, + }; + const records = await rpc(url, params, { + cache: { + type: "disk", + update: "once", + callback: (records, hasChanged) => { + if (hasChanged) { + Object.assign(rates, recordsToRates(records)); + } + }, + }, + }); + Object.assign(rates, recordsToRates(records)); + return rates; +} + +/** + * Returns a string representing a monetary value. The result takes into account + * the user settings (to display the correct decimal separator, currency, ...). + * + * @param {number} value the value that should be formatted + * @param {number} [currencyId] the id of the 'res.currency' to use + * @param {Object} [options] + * additional options to override the values in the python description of the + * field. + * @param {Object} [options.data] a mapping of field names to field values, + * required with options.currencyField + * @param {boolean} [options.noSymbol] this currency has not a sympbol + * @param {boolean} [options.humanReadable] if true, large numbers are formatted + * to a human readable format. + * @param {number} [options.minDigits] see @humanNumber + * @param {boolean} [options.trailingZeros] if false, numbers will have zeros + * to the right of the last non-zero digit hidden + * @param {[number, number]} [options.digits] the number of digits that should + * be used, instead of the default digits precision in the field. The first + * number is always ignored (legacy constraint) + * @param {number} [options.minDigits] the minimum number of decimal digits to display. + * Displays maximum 6 decimal places if no precision is provided. + * @returns {string} + */ +export function formatCurrency(amount, currencyId, options = {}) { + const currency = getCurrency(currencyId); + + const digits = (options.digits !== undefined)? options.digits : (currency && currency.digits) + + let formattedAmount; + if (options.humanReadable) { + formattedAmount = humanNumber(amount, { + decimals: digits ? digits[1] : 2, + minDigits: options.minDigits, + }); + } else { + formattedAmount = formatFloat(amount, { digits, minDigits: options.minDigits, trailingZeros: options.trailingZeros }); + } + + if (!currency || options.noSymbol) { + return formattedAmount; + } + const formatted = [currency.symbol, formattedAmount]; + if (currency.position === "after") { + formatted.reverse(); + } + return formatted.join(nbsp); +} diff --git a/frontend/web/static/src/core/datetime/datetime_input.js b/frontend/web/static/src/core/datetime/datetime_input.js new file mode 100644 index 0000000..cca9cc8 --- /dev/null +++ b/frontend/web/static/src/core/datetime/datetime_input.js @@ -0,0 +1,48 @@ +import { Component } from "@odoo/owl"; +import { omit } from "../utils/objects"; +import { DateTimePicker } from "./datetime_picker"; +import { useDateTimePicker } from "./datetime_picker_hook"; + +/** + * @typedef {import("./datetime_picker").DateTimePickerProps & { + * format?: string; + * id?: string; + * onApply?: (value: DateTime) => any; + * onChange?: (value: DateTime) => any; + * placeholder?: string; + * }} DateTimeInputProps + */ + +const dateTimeInputOwnProps = { + format: { type: String, optional: true }, + id: { type: String, optional: true }, + class: { type: String, optional: true }, + onChange: { type: Function, optional: true }, + onApply: { type: Function, optional: true }, + placeholder: { type: String, optional: true }, + disabled: { type: Boolean, optional: true }, +}; + +/** @extends {Component} */ +export class DateTimeInput extends Component { + static props = { + ...DateTimePicker.props, + ...dateTimeInputOwnProps, + }; + + static template = "web.DateTimeInput"; + + setup() { + const getPickerProps = () => omit(this.props, ...Object.keys(dateTimeInputOwnProps)); + + useDateTimePicker({ + format: this.props.format, + showSeconds: this.props.rounding <= 0, + get pickerProps() { + return getPickerProps(); + }, + onApply: (...args) => this.props.onApply?.(...args), + onChange: (...args) => this.props.onChange?.(...args), + }); + } +} diff --git a/frontend/web/static/src/core/datetime/datetime_input.xml b/frontend/web/static/src/core/datetime/datetime_input.xml new file mode 100644 index 0000000..6135316 --- /dev/null +++ b/frontend/web/static/src/core/datetime/datetime_input.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/frontend/web/static/src/core/datetime/datetime_picker.js b/frontend/web/static/src/core/datetime/datetime_picker.js new file mode 100644 index 0000000..d67e4cb --- /dev/null +++ b/frontend/web/static/src/core/datetime/datetime_picker.js @@ -0,0 +1,646 @@ +import { Component, onWillRender, onWillUpdateProps, useState } from "@odoo/owl"; +import { _t } from "@web/core/l10n/translation"; +import { MAX_VALID_DATE, MIN_VALID_DATE, clampDate, isInRange, today } from "../l10n/dates"; +import { localization } from "../l10n/localization"; +import { ensureArray } from "../utils/arrays"; +import { TimePicker } from "@web/core/time_picker/time_picker"; +import { Time } from "@web/core/l10n/time"; + +const { DateTime, Info } = luxon; + +/** + * @typedef DateItem + * @property {string} id + * @property {boolean} includesToday + * @property {boolean} isOutOfRange + * @property {boolean} isValid + * @property {string} label + * @property {DateRange} range + * @property {string} extraClass + * + * @typedef {"today" | NullableDateTime} DateLimit + * + * @typedef {[DateTime, DateTime]} DateRange + * + * @typedef {luxon["DateTime"]["prototype"]} DateTime + * + * @typedef DateTimePickerProps + * @property {number} [focusedDateIndex=0] + * @property {boolean} [showWeekNumbers=true] + * @property {DaysOfWeekFormat} [daysOfWeekFormat="narrow"] + * @property {DateLimit} [maxDate] + * @property {PrecisionLevel} [maxPrecision="decades"] + * @property {DateLimit} [minDate] + * @property {PrecisionLevel} [minPrecision="days"] + * @property {() => any} [onReset] + * @property {(value: DateTime | DateRange, unit: "date" | "time") => any} [onSelect] + * @property {() => any} [onToggleRange] + * @property {boolean} [range] + * @property {number} [rounding=5] the rounding in minutes, pass 0 to show seconds, pass 1 to avoid + * rounding minutes without displaying seconds. + * @property {() => boolean} [showRangeToggler] + * @property {{ buttons?: any }} [slots] + * @property {"date" | "datetime"} [type] + * @property {NullableDateTime | NullableDateRange} [value] + * @property {(date: DateTime) => boolean} [isDateValid] + * @property {(date: DateTime) => string} [dayCellClass] + * + * @typedef {DateItem | MonthItem} Item + * + * @typedef MonthItem + * @property {[string, string][]} daysOfWeek + * @property {string} id + * @property {number} number + * @property {WeekItem[]} weeks + * + * @typedef {import("@web/core/l10n/dates").NullableDateTime} NullableDateTime + * + * @typedef {import("@web/core/l10n/dates").NullableDateRange} NullableDateRange + * + * @typedef PrecisionInfo + * @property {(date: DateTime, params: Partial) => string} getTitle + * @property {(date: DateTime, params: Partial) => Item[]} getItems + * @property {string} mainTitle + * @property {string} nextTitle + * @property {string} prevTitle + * @property {Record} step + * + * @typedef {"days" | "months" | "years" | "decades"} PrecisionLevel + * + * @typedef {"short" | "narrow"} DaysOfWeekFormat + * + * @typedef WeekItem + * @property {DateItem[]} days + * @property {number} number + */ + +/** + * @param {DateTime} date + */ +const getStartOfDecade = (date) => Math.floor(date.year / 10) * 10; + +/** + * @param {DateTime} date + */ +const getStartOfCentury = (date) => Math.floor(date.year / 100) * 100; + +/** + * @param {DateTime} date + */ +const getStartOfWeek = (date) => { + const { weekStart } = localization; + return date.set({ weekday: date.weekday < weekStart ? weekStart - 7 : weekStart }); +}; + +/** + * @param {number} min + * @param {number} max + */ +const numberRange = (min, max) => [...Array(max - min)].map((_, i) => i + min); + +/** + * @param {NullableDateTime | "today"} value + * @param {NullableDateTime | "today"} defaultValue + */ +const parseLimitDate = (value, defaultValue) => + clampDate(value === "today" ? today() : value || defaultValue, MIN_VALID_DATE, MAX_VALID_DATE); + +/** + * @param {Object} params + * @param {boolean} [params.isOutOfRange=false] + * @param {boolean} [params.isValid=true] + * @param {keyof DateTime} params.label + * @param {string} [params.extraClass] + * @param {[DateTime, DateTime]} params.range + * @returns {DateItem} + */ +const toDateItem = ({ isOutOfRange = false, isValid = true, label, range, extraClass }) => ({ + id: range[0].toISODate(), + includesToday: isInRange(today(), range), + isOutOfRange, + isValid, + label: String(range[0][label]), + range, + extraClass, +}); + +/** + * @param {DateItem[]} weekDayItems + * @returns {WeekItem} + */ +const toWeekItem = (weekDayItems) => ({ + number: weekDayItems[3].range[0].weekNumber, + days: weekDayItems, +}); + +/** + * Precision levels + * @type {Map} + */ +const PRECISION_LEVELS = new Map() + .set("days", { + mainTitle: _t("Select month"), + nextTitle: _t("Next month"), + prevTitle: _t("Previous month"), + step: { month: 1 }, + getTitle: (date) => `${date.monthLong} ${date.year}`, + getItems: (date, { maxDate, minDate, showWeekNumbers, isDateValid, dayCellClass }) => { + const startDates = [date]; + + /** @type {WeekItem[]} */ + const lastWeeks = []; + let shouldAddLastWeek = false; + + const dayItems = startDates.map((date, i) => { + const monthRange = [date.startOf("month"), date.endOf("month")]; + /** @type {WeekItem[]} */ + const weeks = []; + + // Generate 6 weeks for current month + let startOfNextWeek = getStartOfWeek(monthRange[0]); + for (let w = 0; w < WEEKS_PER_MONTH; w++) { + const weekDayItems = []; + // Generate all days of the week + for (let d = 0; d < DAYS_PER_WEEK; d++) { + const day = startOfNextWeek.plus({ day: d }); + const range = [day, day.endOf("day")]; + const dayItem = toDateItem({ + isOutOfRange: !isInRange(day, monthRange), + isValid: isInRange(range, [minDate, maxDate]) && isDateValid?.(day), + label: "day", + range, + extraClass: dayCellClass?.(day) || "", + }); + weekDayItems.push(dayItem); + if (d === DAYS_PER_WEEK - 1) { + startOfNextWeek = day.plus({ day: 1 }); + } + if (w === WEEKS_PER_MONTH - 1) { + shouldAddLastWeek = true; + } + } + + const weekItem = toWeekItem(weekDayItems); + if (w === WEEKS_PER_MONTH - 1) { + lastWeeks.push(weekItem); + } else { + weeks.push(weekItem); + } + } + + // Generate days of week labels + const daysOfWeek = weeks[0].days.map((d) => [ + d.range[0].weekdayShort, + d.range[0].weekdayLong, + Info.weekdays("narrow", { locale: d.range[0].locale })[d.range[0].weekday - 1], + ]); + if (showWeekNumbers) { + daysOfWeek.unshift(["", _t("Week numbers"), ""]); + } + + return { + id: `__month__${i}`, + number: monthRange[0].month, + daysOfWeek, + weeks, + }; + }); + + if (shouldAddLastWeek) { + // Add last empty week item if the other month has an extra week + for (let i = 0; i < dayItems.length; i++) { + dayItems[i].weeks.push(lastWeeks[i]); + } + } + + return dayItems; + }, + }) + .set("months", { + mainTitle: _t("Select year"), + nextTitle: _t("Next year"), + prevTitle: _t("Previous year"), + step: { year: 1 }, + getTitle: (date) => String(date.year), + getItems: (date, { maxDate, minDate }) => { + const startOfYear = date.startOf("year"); + return numberRange(0, 12).map((i) => { + const startOfMonth = startOfYear.plus({ month: i }); + const range = [startOfMonth, startOfMonth.endOf("month")]; + return toDateItem({ + isValid: isInRange(range, [minDate, maxDate]), + label: "monthShort", + range, + }); + }); + }, + }) + .set("years", { + mainTitle: _t("Select decade"), + nextTitle: _t("Next decade"), + prevTitle: _t("Previous decade"), + step: { year: 10 }, + getTitle: (date) => `${getStartOfDecade(date) - 1} - ${getStartOfDecade(date) + 10}`, + getItems: (date, { maxDate, minDate }) => { + const startOfDecade = date.startOf("year").set({ year: getStartOfDecade(date) }); + return numberRange(-GRID_MARGIN, GRID_COUNT + GRID_MARGIN).map((i) => { + const startOfYear = startOfDecade.plus({ year: i }); + const range = [startOfYear, startOfYear.endOf("year")]; + return toDateItem({ + isOutOfRange: i < 0 || i >= GRID_COUNT, + isValid: isInRange(range, [minDate, maxDate]), + label: "year", + range, + }); + }); + }, + }) + .set("decades", { + mainTitle: _t("Select century"), + nextTitle: _t("Next century"), + prevTitle: _t("Previous century"), + step: { year: 100 }, + getTitle: (date) => `${getStartOfCentury(date) - 10} - ${getStartOfCentury(date) + 100}`, + getItems: (date, { maxDate, minDate }) => { + const startOfCentury = date.startOf("year").set({ year: getStartOfCentury(date) }); + return numberRange(-GRID_MARGIN, GRID_COUNT + GRID_MARGIN).map((i) => { + const startOfDecade = startOfCentury.plus({ year: i * 10 }); + const range = [startOfDecade, startOfDecade.plus({ year: 10, millisecond: -1 })]; + return toDateItem({ + label: "year", + isOutOfRange: i < 0 || i >= GRID_COUNT, + isValid: isInRange(range, [minDate, maxDate]), + range, + }); + }); + }, + }); + +// Other constants +const GRID_COUNT = 10; +const GRID_MARGIN = 1; +const NULLABLE_DATETIME_PROPERTY = [DateTime, { value: false }, { value: null }]; + +const DAYS_PER_WEEK = 7; +const WEEKS_PER_MONTH = 6; + +/** @extends {Component} */ +export class DateTimePicker extends Component { + static props = { + focusedDateIndex: { type: Number, optional: true }, + showWeekNumbers: { type: Boolean, optional: true }, + daysOfWeekFormat: { type: String, optional: true }, + maxDate: { type: [NULLABLE_DATETIME_PROPERTY, { value: "today" }], optional: true }, + maxPrecision: { + type: [...PRECISION_LEVELS.keys()].map((value) => ({ value })), + optional: true, + }, + minDate: { type: [NULLABLE_DATETIME_PROPERTY, { value: "today" }], optional: true }, + minPrecision: { + type: [...PRECISION_LEVELS.keys()].map((value) => ({ value })), + optional: true, + }, + onReset: { type: Function, optional: true }, + onSelect: { type: Function, optional: true }, + onToggleRange: { type: Function, optional: true }, + range: { type: Boolean, optional: true }, + rounding: { type: Number, optional: true }, + showRangeToggler: { type: Boolean, optional: true }, + slots: { + type: Object, + shape: { buttons: { type: Object, optional: true } }, + optional: true, + }, + type: { type: [{ value: "date" }, { value: "datetime" }], optional: true }, + value: { + type: [ + NULLABLE_DATETIME_PROPERTY, + { type: Array, element: NULLABLE_DATETIME_PROPERTY }, + ], + optional: true, + }, + isDateValid: { type: Function, optional: true }, + dayCellClass: { type: Function, optional: true }, + tz: { type: String, optional: true }, + }; + + static defaultProps = { + focusedDateIndex: 0, + daysOfWeekFormat: "narrow", + maxPrecision: "decades", + minPrecision: "days", + rounding: 5, + showWeekNumbers: true, + type: "datetime", + }; + + static template = "web.DateTimePicker"; + static components = { TimePicker }; + + //------------------------------------------------------------------------- + // Getters + //------------------------------------------------------------------------- + + get activePrecisionLevel() { + return PRECISION_LEVELS.get(this.state.precision); + } + + get isLastPrecisionLevel() { + return ( + this.allowedPrecisionLevels.indexOf(this.state.precision) === + this.allowedPrecisionLevels.length - 1 + ); + } + + get titles() { + return ensureArray(this.title); + } + + //------------------------------------------------------------------------- + // Lifecycle + //------------------------------------------------------------------------- + + setup() { + /** @type {PrecisionLevel[]} */ + this.allowedPrecisionLevels = []; + /** @type {Item[]} */ + this.items = []; + this.title = ""; + this.shouldAdjustFocusDate = false; + + this.state = useState({ + /** @type {DateTime | null} */ + focusDate: null, + /** @type {DateTime | null} */ + hoveredDate: null, + /** @type {Time[]} */ + timeValues: [], + /** @type {PrecisionLevel} */ + precision: this.props.minPrecision, + }); + + this.onPropsUpdated(this.props); + onWillUpdateProps((nextProps) => this.onPropsUpdated(nextProps)); + + onWillRender(() => this.onWillRender()); + } + + /** + * @param {DateTimePickerProps} props + */ + onPropsUpdated(props) { + /** @type {[NullableDateTime] | NullableDateRange} */ + this.values = ensureArray(props.value).map((value) => + value && !value.isValid ? null : value + ); + this.allowedPrecisionLevels = this.filterPrecisionLevels( + props.minPrecision, + props.maxPrecision + ); + + this.maxDate = parseLimitDate(props.maxDate, MAX_VALID_DATE); + this.minDate = parseLimitDate(props.minDate, MIN_VALID_DATE); + if (this.props.type === "date") { + this.maxDate = this.maxDate.endOf("day"); + this.minDate = this.minDate.startOf("day"); + } + + if (this.maxDate < this.minDate) { + throw new Error(`DateTimePicker error: given "maxDate" comes before "minDate".`); + } + + this.state.timeValues = this.getTimeValues(props); + this.shouldAdjustFocusDate = !props.range; + this.adjustFocus(this.values, props.focusedDateIndex); + } + + onWillRender() { + const { dayCellClass, focusedDateIndex, isDateValid, range, showWeekNumbers } = this.props; + const { focusDate, hoveredDate } = this.state; + const precision = this.activePrecisionLevel; + const getterParams = { + maxDate: this.maxDate, + minDate: this.minDate, + showWeekNumbers: showWeekNumbers ?? !range, + isDateValid, + dayCellClass, + }; + + this.title = precision.getTitle(focusDate); + this.items = precision.getItems(focusDate, getterParams); + + this.selectedRange = [...this.values]; + if (range && focusedDateIndex > 0 && (!this.values[1] || hoveredDate > this.values[0])) { + this.selectedRange[1] = hoveredDate; + } + } + + //------------------------------------------------------------------------- + // Methods + //------------------------------------------------------------------------- + + /** + * @param {NullableDateTime[]} values + * @param {number} focusedDateIndex + */ + adjustFocus(values, focusedDateIndex) { + if (!this.shouldAdjustFocusDate && this.state.focusDate) { + return; + } + + const dateToFocus = + values[focusedDateIndex] || values[focusedDateIndex === 1 ? 0 : 1] || today(); + + this.shouldAdjustFocusDate = false; + this.state.focusDate = this.clamp(dateToFocus.startOf("month")); + } + + /** + * @param {DateTime} value + */ + clamp(value) { + return clampDate(value, this.minDate, this.maxDate); + } + + /** + * @param {PrecisionLevel} minPrecision + * @param {PrecisionLevel} maxPrecision + */ + filterPrecisionLevels(minPrecision, maxPrecision) { + const levels = [...PRECISION_LEVELS.keys()]; + return levels.slice(levels.indexOf(minPrecision), levels.indexOf(maxPrecision) + 1); + } + + /** + * Returns various flags indicating what ranges the current date item belongs + * to. Note that these ranges are computed differently according to the current + * value mode (range or single date). This is done to simplify CSS selectors. + * - Selected Range: + * > range: current values with hovered date applied + * > single date: just the hovered date + * - Highlighted Range: + * > range: union of selection range and current values + * > single date: just the current value + * - Current Range (range only): + * > range: current start date or current end date. + * @param {DateItem} item + */ + getActiveRangeInfo({ range }) { + const result = { + isSelected: isInRange(this.selectedRange, range), + isSelectStart: false, + isSelectEnd: false, + isHighlighted: isInRange(this.state.hoveredDate, range), + }; + + if (this.props.range) { + if (result.isSelected) { + const [selectStart, selectEnd] = this.selectedRange.sort(); + result.isSelectStart = !selectStart || isInRange(selectStart, range); + result.isSelectEnd = !selectEnd || isInRange(selectEnd, range); + } + } else { + result.isSelectStart = result.isSelectEnd = result.isSelected; + } + + return result; + } + + /** + * @param {DateTimePickerProps} props + */ + getTimeValues(props) { + const timeValues = this.values.map( + (val, index) => + new Time({ + hour: + index === 1 && !this.values[1] + ? (val || DateTime.local()).hour + 1 + : (val || DateTime.local()).hour, + minute: val?.minute || 0, + second: val?.second || 0, + }) + ); + + if (props.range) { + return timeValues; + } else { + const values = []; + values[props.focusedDateIndex] = timeValues[props.focusedDateIndex]; + return values; + } + } + + /** + * @param {DateItem} item + */ + isSelectedDate({ range }) { + return this.values.some((value) => isInRange(value, range)); + } + + /** + * Goes to the next panel (e.g. next month if precision is "days"). + * If an event is given it will be prevented. + * @param {PointerEvent} ev + */ + next(ev) { + ev.preventDefault(); + const { step } = this.activePrecisionLevel; + this.state.focusDate = this.clamp(this.state.focusDate.plus(step)); + } + + /** + * Goes to the previous panel (e.g. previous month if precision is "days"). + * If an event is given it will be prevented. + * @param {PointerEvent} ev + */ + previous(ev) { + ev.preventDefault(); + const { step } = this.activePrecisionLevel; + this.state.focusDate = this.clamp(this.state.focusDate.minus(step)); + } + + /** + * @param {number} valueIndex + * @param {Time} newTime + */ + onTimeChange(valueIndex, newTime) { + this.state.timeValues[valueIndex] = newTime; + const value = this.values[valueIndex] || today(); + this.validateAndSelect(value, valueIndex, "time"); + } + + /** + * @param {DateTime} value + * @param {number} valueIndex + * @param {"date" | "time"} unit + */ + validateAndSelect(value, valueIndex, unit) { + if (!this.props.onSelect) { + // No onSelect handler + return false; + } + + const result = [...this.values]; + result[valueIndex] = value; + + if (this.props.type === "datetime") { + // Adjusts result according to the current time values + const { hour, minute, second } = this.state.timeValues[valueIndex]; + result[valueIndex] = result[valueIndex].set({ hour, minute, second }); + } + if (!isInRange(result[valueIndex], [this.minDate, this.maxDate])) { + // Date is outside range defined by min and max dates + return false; + } + this.props.onSelect(result.length === 2 ? result : result[0], unit); + return true; + } + + /** + * Returns whether the zoom has occurred + * @param {DateTime} date + */ + zoomIn(date) { + const index = this.allowedPrecisionLevels.indexOf(this.state.precision) - 1; + if (index in this.allowedPrecisionLevels) { + this.state.focusDate = this.clamp(date); + this.state.precision = this.allowedPrecisionLevels[index]; + return true; + } + return false; + } + + /** + * Returns whether the zoom has occurred + */ + zoomOut() { + const index = this.allowedPrecisionLevels.indexOf(this.state.precision) + 1; + if (index in this.allowedPrecisionLevels) { + this.state.precision = this.allowedPrecisionLevels[index]; + return true; + } + return false; + } + + /** + * Happens when a date item is selected: + * - first tries to zoom in on the item + * - if could not zoom in: date is considered as final value and triggers a hard select + * @param {DateItem} dateItem + */ + zoomOrSelect(dateItem) { + if (!dateItem.isValid) { + // Invalid item + return; + } + if (this.zoomIn(dateItem.range[0])) { + // Zoom was successful + return; + } + const [value] = dateItem.range; + const valueIndex = this.props.focusedDateIndex; + const isValid = this.validateAndSelect(value, valueIndex, "date"); + this.shouldAdjustFocusDate = isValid && !this.props.range; + } +} diff --git a/frontend/web/static/src/core/datetime/datetime_picker.scss b/frontend/web/static/src/core/datetime/datetime_picker.scss new file mode 100644 index 0000000..3f7ec1f --- /dev/null +++ b/frontend/web/static/src/core/datetime/datetime_picker.scss @@ -0,0 +1,92 @@ +.o_datetime_picker { + --DateTimePicker__Template-rows: 3; + --DateTimePicker__Template-columns: 4; + --DateTimePicker__Day-template-rows: 6; + + width: $o-datetime-picker-width; + + // Day + .o_selected { + color: $o-component-active-color; + background: $o-component-active-bg; + } + + .o_select_start, + .o_select_end { + --selected-day-color: #{mix(lighten($o-component-active-border, 10%), $o-component-active-bg, 15%)}; + --percent: calc(100% / sqrt(2)); + background: + #{$o-component-active-bg} + radial-gradient( + circle, + var(--selected-day-color) 0% var(--percent), + transparent var(--percent) 100% + ) + ; + } + + .o_select_start{ + border-top-left-radius: 50%; + border-bottom-left-radius: 50%; + } + + .o_select_end { + border-top-right-radius: 50%; + border-bottom-right-radius: 50%; + } + + .o_today > div { + aspect-ratio: 1; + background-color: $o-calendar-today-background-color; + color: $o-calendar-today-color; + } + + // Grids + + .o_date_picker { + grid-template-rows: repeat(var(--DateTimePicker__Day-template-rows), 1fr); + grid-template-columns: repeat(var(--DateTimePicker__Day-template-columns), 1fr); + } + + .o_date_item_picker { + grid-template-rows: repeat(var(--DateTimePicker__Template-rows), 1fr); + grid-template-columns: repeat(var(--DateTimePicker__Template-columns), 1fr); + } + + // Utilities + + .o_date_item_picker .o_datetime_button { + &.o_selected:not(.o_select_start, .o_select_end) { + background: $o-component-active-bg; + color: $o-component-active-color; + } + } + + .o_center { + display: grid; + place-items: center; + } + + .o_date_item_cell { + aspect-ratio: 1; + position: relative; + + &:hover, &:focus { + --DateTimePicker__date-cell-border-color-hover: #{$o-component-active-border}; + } + + &:not([disabled])::before { + @include o-position-absolute(0,0,0,0); + + content: ''; + aspect-ratio: 1; + border: $border-width solid var(--DateTimePicker__date-cell-border-color-hover); + border-radius: $border-radius-pill; + pointer-events: none; + } + } + + .o_week_number_cell { + font-variant: tabular-nums; + } +} diff --git a/frontend/web/static/src/core/datetime/datetime_picker.xml b/frontend/web/static/src/core/datetime/datetime_picker.xml new file mode 100644 index 0000000..fef537f --- /dev/null +++ b/frontend/web/static/src/core/datetime/datetime_picker.xml @@ -0,0 +1,153 @@ + + + +
+ +
+ +
+
+
+ + + +
+ + + +
+
+ +
+
+
+
+
+
+
+
+
+ + + +
+ +
+
+ + +
+ + +
+ +
+
+
+
+ + +
+ + + + + + + +
+
+ diff --git a/frontend/web/static/src/core/datetime/datetime_picker_hook.js b/frontend/web/static/src/core/datetime/datetime_picker_hook.js new file mode 100644 index 0000000..d30a641 --- /dev/null +++ b/frontend/web/static/src/core/datetime/datetime_picker_hook.js @@ -0,0 +1,36 @@ +import { onWillDestroy, useRef } from "@odoo/owl"; +import { useService } from "@web/core/utils/hooks"; + +/** + * @typedef {import("./datetimepicker_service").DateTimePickerServiceParams & { + * endDateRefName?: string; + * startDateRefName?: string; + * }} DateTimePickerHookParams + */ + +/** + * @param {DateTimePickerHookParams} params + */ +export function useDateTimePicker(params) { + function getInputs() { + return inputRefs.map((ref) => ref.el); + } + + const inputRefs = [ + useRef(params.startDateRefName || "start-date"), + useRef(params.endDateRefName || "end-date"), + ]; + + // Need original object since 'pickerProps' (or any other param) can be defined + // as getters + const serviceParams = Object.assign(Object.create(params), { + getInputs, + useOwlHooks: true, + }); + + const picker = useService("datetime_picker").create(serviceParams); + onWillDestroy(() => { + picker.disable(); + }); + return picker; +} diff --git a/frontend/web/static/src/core/datetime/datetime_picker_popover.js b/frontend/web/static/src/core/datetime/datetime_picker_popover.js new file mode 100644 index 0000000..53c1923 --- /dev/null +++ b/frontend/web/static/src/core/datetime/datetime_picker_popover.js @@ -0,0 +1,31 @@ +import { Component } from "@odoo/owl"; +import { useHotkey } from "../hotkeys/hotkey_hook"; +import { DateTimePicker } from "./datetime_picker"; + +/** + * @typedef {import("./datetime_picker").DateTimePickerProps} DateTimePickerProps + * + * @typedef DateTimePickerPopoverProps + * @property {() => void} close + * @property {DateTimePickerProps} pickerProps + */ + +/** @extends {Component} */ +export class DateTimePickerPopover extends Component { + static components = { DateTimePicker }; + + static props = { + close: Function, // Given by the Popover service + pickerProps: { type: Object, shape: DateTimePicker.props }, + }; + + static template = "web.DateTimePickerPopover"; + + //------------------------------------------------------------------------- + // Lifecycle + //------------------------------------------------------------------------- + + setup() { + useHotkey("enter", () => this.props.close()); + } +} diff --git a/frontend/web/static/src/core/datetime/datetime_picker_popover.xml b/frontend/web/static/src/core/datetime/datetime_picker_popover.xml new file mode 100644 index 0000000..e24333d --- /dev/null +++ b/frontend/web/static/src/core/datetime/datetime_picker_popover.xml @@ -0,0 +1,26 @@ + + + + + +
+ + +
+
+
+
+
diff --git a/frontend/web/static/src/core/datetime/datetimepicker_service.js b/frontend/web/static/src/core/datetime/datetimepicker_service.js new file mode 100644 index 0000000..afdf042 --- /dev/null +++ b/frontend/web/static/src/core/datetime/datetimepicker_service.js @@ -0,0 +1,551 @@ +import { markRaw, onPatched, onWillRender, reactive, useEffect, useRef } from "@odoo/owl"; +import { areDatesEqual, formatDate, formatDateTime, parseDate, parseDateTime } from "../l10n/dates"; +import { makePopover } from "../popover/popover_hook"; +import { registry } from "../registry"; +import { ensureArray, zip, zipWith } from "../utils/arrays"; +import { shallowEqual } from "../utils/objects"; +import { DateTimePicker } from "./datetime_picker"; +import { DateTimePickerPopover } from "./datetime_picker_popover"; + +/** + * @typedef {luxon["DateTime"]["prototype"]} DateTime + * + * @typedef {import("./datetime_picker").DateTimePickerProps} DateTimePickerProps + * @typedef {import("../popover/popover_hook").PopoverHookReturnType} PopoverHookReturnType + * @typedef {import("../popover/popover_service").PopoverServiceAddOptions} PopoverServiceAddOptions + * @typedef {import("@odoo/owl").Component} Component + * @typedef {ReturnType} OwlRef + * + * @typedef {{ + * createPopover?: (component: Component, options: PopoverServiceAddOptions) => PopoverHookReturnType; + * ensureVisibility?: () => boolean; + * format?: string; + * getInputs?: () => HTMLElement[]; + * onApply?: (value: DateTimePickerProps["value"]) => any; + * onChange?: (value: DateTimePickerProps["value"]) => any; + * onClose?: () => any; + * pickerProps?: DateTimePickerProps; + * showSeconds?: boolean; + * target: HTMLElement | string; + * useOwlHooks?: boolean; + * }} DateTimePickerServiceParams + */ + +/** + * @template {object} T + * @param {T} obj + */ +function markValuesRaw(obj) { + /** @type {T} */ + const copy = {}; + for (const [key, value] of Object.entries(obj)) { + if (value && typeof value === "object") { + copy[key] = markRaw(value); + } else { + copy[key] = value; + } + } + return copy; +} + +/** + * @param {Record} props + */ +function stringifyProps(props) { + const copy = {}; + for (const [key, value] of Object.entries(props)) { + copy[key] = JSON.stringify(value); + } + return copy; +} + +const FOCUS_CLASSNAME = "text-primary"; + +const formatters = { + date: formatDate, + datetime: formatDateTime, +}; +const listenedElements = new WeakSet(); +const parsers = { + date: parseDate, + datetime: parseDateTime, +}; + +export const datetimePickerService = { + dependencies: ["popover"], + start(env, { popover: popoverService }) { + const dateTimePickerList = new Set(); + return { + /** + * @param {DateTimePickerServiceParams} [params] + */ + create(params = {}) { + /** + * Wrapper method on the "onApply" callback to only call it when the + * value has changed, and set other internal variables accordingly. + */ + async function apply() { + const { value } = pickerProps; + const stringValue = JSON.stringify(value); + if ( + stringValue === lastAppliedStringValue || + stringValue === stringProps.value + ) { + return; + } + + lastAppliedStringValue = stringValue; + inputsChanged = ensureArray(value).map(() => false); + + await params.onApply?.(value); + + stringProps.value = stringValue; + } + + function enable() { + for (const [el, value] of zip( + getInputs(), + ensureArray(pickerProps.value), + true + )) { + updateInput(el, value); + if (el && !el.disabled && !el.readOnly && !listenedElements.has(el)) { + listenedElements.add(el); + el.addEventListener("change", onInputChange); + el.addEventListener("click", onInputClick); + el.addEventListener("focus", onInputFocus); + el.addEventListener("keydown", onInputKeydown); + } + } + const calendarIconGroupEl = getInput(0)?.parentElement.querySelector( + ".o_input_group_date_icon" + ); + if (calendarIconGroupEl) { + calendarIconGroupEl.classList.add("cursor-pointer"); + calendarIconGroupEl.addEventListener("click", () => open(0)); + } + return () => {}; + } + + /** + * Ensures the current focused input (indicated by `pickerProps.focusedDateIndex`) + * is actually focused. + */ + function focusActiveInput() { + const inputEl = getInput(pickerProps.focusedDateIndex); + if (!inputEl) { + shouldFocus = true; + return; + } + + const { activeElement } = inputEl.ownerDocument; + if (activeElement !== inputEl) { + inputEl.focus(); + } + setInputFocus(inputEl); + } + + /** + * @param {number} valueIndex + * @returns {HTMLInputElement | null} + */ + function getInput(valueIndex) { + const el = getInputs()[valueIndex]; + if (el?.isConnected) { + return el; + } + return null; + } + + /** + * Returns the appropriate root element to attach the popover: + * - if the value is a range: the closest common parent of the two inputs + * - if not: the first input + */ + function getPopoverTarget() { + const target = getTarget(); + if (target) { + return target; + } + if (pickerProps.range) { + let parentElement = getInput(0).parentElement; + const inputEls = getInputs(); + while ( + parentElement && + !inputEls.every((inputEl) => parentElement.contains(inputEl)) + ) { + parentElement = parentElement.parentElement; + } + return parentElement || getInput(0); + } else { + return getInput(0); + } + } + + function getTarget() { + return targetRef ? targetRef.el : params.target; + } + + function isOpen() { + return popover.isOpen; + } + + /** + * Inputs "change" event handler. This will trigger an "onApply" callback if + * one of the following is true: + * - there is only one input; + * - the popover is closed; + * - the other input has also changed. + * + * @param {Event} ev + */ + function onInputChange(ev) { + updateValueFromInputs(); + inputsChanged[ev.target === getInput(1) ? 1 : 0] = true; + if (!isOpen() || inputsChanged.every(Boolean)) { + saveAndClose(); + } + } + + /** + * @param {PointerEvent} ev + */ + function onInputClick({ target }) { + open(target === getInput(1) ? 1 : 0); + } + + /** + * @param {FocusEvent} ev + */ + function onInputFocus({ target }) { + pickerProps.focusedDateIndex = target === getInput(1) ? 1 : 0; + setInputFocus(target); + } + + /** + * @param {KeyboardEvent} ev + */ + function onInputKeydown(ev) { + if (ev.key == "Enter" && ev.ctrlKey) { + ev.preventDefault(); + updateValueFromInputs(); + return open(ev.target === getInput(1) ? 1 : 0); + } + switch (ev.key) { + case "Enter": + case "Escape": { + return saveAndClose(); + } + case "Tab": { + if ( + !getInput(0) || + !getInput(1) || + ev.target !== getInput(ev.shiftKey ? 1 : 0) + ) { + return saveAndClose(); + } + } + } + } + + /** + * @param {number} inputIndex Input from which to open the picker + */ + function open(inputIndex) { + pickerProps.focusedDateIndex = inputIndex; + + if (!isOpen()) { + const popoverTarget = getPopoverTarget(); + if (ensureVisibility()) { + const { marginBottom } = popoverTarget.style; + // Adds enough space for the popover to be displayed below the target + // even on small screens. + popoverTarget.style.marginBottom = `100vh`; + popoverTarget.scrollIntoView(true); + restoreTargetMargin = async () => { + popoverTarget.style.marginBottom = marginBottom; + }; + } + for (const picker of dateTimePickerList) { + picker.close(); + } + popover.open(popoverTarget, { pickerProps }); + } + + focusActiveInput(); + } + + /** + * @template {"format" | "parse"} T + * @param {T} operation + * @param {T extends "format" ? DateTime : string} value + * @returns {[T extends "format" ? string : DateTime, null] | [null, Error]} + */ + function safeConvert(operation, value) { + const { type } = pickerProps; + const convertFn = (operation === "format" ? formatters : parsers)[type]; + const options = { tz: pickerProps.tz, format: params.format }; + if (operation === "format") { + options.showSeconds = params.showSeconds ?? true; + } + try { + return [convertFn(value, options), null]; + } catch (error) { + if (error?.name === "ConversionError") { + return [null, error]; + } else { + throw error; + } + } + } + + /** + * Wrapper method to ensure the "onApply" callback is called, either: + * - by closing the popover (if any); + * - or by directly calling "apply", without updating the values. + */ + function saveAndClose() { + if (isOpen()) { + // apply will be done in the "onClose" callback + popover.close(); + } else { + apply(); + } + } + + /** + * Updates class names on given inputs according to the currently selected input. + * + * @param {HTMLInputElement | null} input + */ + function setFocusClass(input) { + for (const el of getInputs()) { + if (el) { + el.classList.toggle(FOCUS_CLASSNAME, isOpen() && el === input); + } + } + } + + /** + * Applies class names to all inputs according to whether they are focused or not. + * + * @param {HTMLInputElement} inputEl + */ + function setInputFocus(inputEl) { + inputEl.selectionStart = 0; + inputEl.selectionEnd = inputEl.value.length; + + setFocusClass(inputEl); + + shouldFocus = false; + } + + /** + * Synchronizes the given input with the given value. + * + * @param {HTMLInputElement} el + * @param {DateTime} value + */ + function updateInput(el, value) { + if (!el) { + return; + } + const [formattedValue] = safeConvert("format", value); + el.value = formattedValue || ""; + } + + /** + * @param {DateTimePickerProps["value"]} value + * @param {"date" | "time"} unit + * @param {"input" | "picker"} source + */ + function updateValue(value, unit, source) { + if (source === "input" && areDatesEqual(pickerProps.value, value)) { + return; + } + + pickerProps.value = value; + + if (pickerProps.range && unit !== "time" && source === "picker") { + if (!value[0]) { + pickerProps.focusedDateIndex = 0; + } else if ( + pickerProps.focusedDateIndex === 0 || + (value[0] && value[1] && value[1] < value[0]) + ) { + // If selecting either: + // - the first value + // - OR a second value before the first: + // Then: + // - Set the DATE (year + month + day) of all values + // to the one that has been selected. + const { year, month, day } = value[pickerProps.focusedDateIndex]; + for (let i = 0; i < value.length; i++) { + value[i] = value[i] && value[i].set({ year, month, day }); + } + pickerProps.focusedDateIndex = 1; + } else { + // If selecting the second value after the first: + // - simply toggle the focus index + pickerProps.focusedDateIndex = + pickerProps.focusedDateIndex === 1 ? 0 : 1; + } + } + + params.onChange?.(value); + } + + function updateValueFromInputs() { + const values = zipWith( + getInputs(), + ensureArray(pickerProps.value), + (el, currentValue) => { + if (!el || el.tagName?.toLowerCase() !== "input") { + return currentValue; + } + const [parsedValue, error] = safeConvert("parse", el.value); + if (error) { + updateInput(el, currentValue); + return currentValue; + } else { + return parsedValue; + } + } + ); + updateValue(values.length === 2 ? values : values[0], "date", "input"); + } + + const createPopover = + params.createPopover || + function defaultCreatePopover(...args) { + return makePopover(popoverService.add, ...args); + }; + const ensureVisibility = + params.ensureVisibility || + function defaultEnsureVisibility() { + return env.isSmall; + }; + const getInputs = + params.getInputs || + function defaultGetInputs() { + return [getTarget(), null]; + }; + + // Hook variables + + /** @type {DateTimePickerProps} */ + const rawPickerProps = { + ...DateTimePicker.defaultProps, + onReset: () => { + updateValue( + ensureArray(pickerProps.value).length === 2 ? [false, false] : false, + "date", + "picker" + ); + saveAndClose(); + }, + onSelect: (value, unit) => { + value &&= markRaw(value); + updateValue(value, unit, "picker"); + if (!pickerProps.range && pickerProps.type === "date") { + saveAndClose(); + } + }, + ...markValuesRaw(params.pickerProps), + }; + const pickerProps = reactive(rawPickerProps, () => { + // Update inputs + for (const [el, value] of zip( + getInputs(), + ensureArray(pickerProps.value), + true + )) { + if (el) { + updateInput(el, value); + // Apply changes immediately if the popover is already closed. + // Otherwise ´apply()´ will be called later on close. + if (!isOpen()) { + apply(); + } + } + } + + shouldFocus = true; + }); + const popover = createPopover(DateTimePickerPopover, { + async onClose() { + updateValueFromInputs(); + setFocusClass(null); + restoreTargetMargin?.(); + restoreTargetMargin = null; + await apply(); + params.onClose?.(); + }, + }); + + /** @type {boolean[]} */ + let inputsChanged = []; + let lastAppliedStringValue = ""; + /** @type {(() => void) | null} */ + let restoreTargetMargin = null; + let shouldFocus = false; + /** @type {Partial} */ + let stringProps = {}; + /** @type {OwlRef | null} */ + let targetRef = null; + + if (params.useOwlHooks) { + if (typeof params.target === "string") { + targetRef = useRef(params.target); + } + + onWillRender(function computeBasePickerProps() { + const nextProps = markValuesRaw(params.pickerProps); + const oldStringProps = stringProps; + + stringProps = stringifyProps(nextProps); + lastAppliedStringValue = stringProps.value; + + if (shallowEqual(oldStringProps, stringProps)) { + return; + } + + inputsChanged = ensureArray(nextProps.value).map(() => false); + + for (const [key, value] of Object.entries(nextProps)) { + if (!areDatesEqual(pickerProps[key], value)) { + pickerProps[key] = value; + } + } + }); + + useEffect(enable, getInputs); + + // Note: this `onPatched` callback must be called after the `useEffect` since + // the effect may change input values that will be selected by the patch callback. + onPatched(function focusIfNeeded() { + if (isOpen() && shouldFocus) { + focusActiveInput(); + } + }); + } else if (typeof params.target === "string") { + throw new Error( + `datetime picker service error: cannot use target as ref name when not using Owl hooks` + ); + } + const picker = { + enable, + disable: () => dateTimePickerList.delete(picker), + isOpen, + open, + close: () => popover.close(), + state: pickerProps, + }; + dateTimePickerList.add(picker); + return picker; + }, + }; + }, +}; + +registry.category("services").add("datetime_picker", datetimePickerService); diff --git a/frontend/web/static/src/core/debug/debug_context.js b/frontend/web/static/src/core/debug/debug_context.js new file mode 100644 index 0000000..1898182 --- /dev/null +++ b/frontend/web/static/src/core/debug/debug_context.js @@ -0,0 +1,83 @@ +import { user } from "@web/core/user"; +import { registry } from "../registry"; + +import { useEffect, useEnv, useSubEnv } from "@odoo/owl"; +const debugRegistry = registry.category("debug"); + +const getAccessRights = async () => { + const rightsToCheck = { + "ir.ui.view": "write", + "ir.rule": "read", + "ir.model.access": "read", + }; + const proms = Object.entries(rightsToCheck).map(([model, operation]) => { + return user.checkAccessRight(model, operation); + }); + const [canEditView, canSeeRecordRules, canSeeModelAccess] = await Promise.all(proms); + const accessRights = { canEditView, canSeeRecordRules, canSeeModelAccess }; + return accessRights; +}; + +class DebugContext { + constructor(defaultCategories) { + this.categories = new Map(defaultCategories.map((cat) => [cat, [{}]])); + } + + activateCategory(category, context) { + const contexts = this.categories.get(category) || new Set(); + contexts.add(context); + this.categories.set(category, contexts); + + return () => { + contexts.delete(context); + if (contexts.size === 0) { + this.categories.delete(category); + } + }; + } + + async getItems(env) { + const accessRights = await getAccessRights(); + return [...this.categories.entries()] + .flatMap(([category, contexts]) => { + return debugRegistry + .category(category) + .getAll() + .map((factory) => factory(Object.assign({ env, accessRights }, ...contexts))); + }) + .filter(Boolean) + .sort((x, y) => { + const xSeq = x.sequence || 1000; + const ySeq = y.sequence || 1000; + return xSeq - ySeq; + }); + } +} + +const debugContextSymbol = Symbol("debugContext"); +export function createDebugContext({ categories = [] } = {}) { + return { [debugContextSymbol]: new DebugContext(categories) }; +} + +export function useOwnDebugContext({ categories = [] } = {}) { + useSubEnv(createDebugContext({ categories })); +} + +export function useEnvDebugContext() { + const debugContext = useEnv()[debugContextSymbol]; + if (!debugContext) { + throw new Error("There is no debug context available in the current environment."); + } + return debugContext; +} + +export function useDebugCategory(category, context = {}) { + const env = useEnv(); + if (env.debug) { + const debugContext = useEnvDebugContext(); + useEffect( + () => debugContext.activateCategory(category, context), + () => [] + ); + } +} diff --git a/frontend/web/static/src/core/debug/debug_menu.js b/frontend/web/static/src/core/debug/debug_menu.js new file mode 100644 index 0000000..c3c859b --- /dev/null +++ b/frontend/web/static/src/core/debug/debug_menu.js @@ -0,0 +1,61 @@ +import { _t } from "@web/core/l10n/translation"; +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; +import { DebugMenuBasic } from "@web/core/debug/debug_menu_basic"; +import { useCommand } from "@web/core/commands/command_hook"; +import { useService } from "@web/core/utils/hooks"; +import { useEnvDebugContext } from "./debug_context"; + +export class DebugMenu extends DebugMenuBasic { + static components = { Dropdown, DropdownItem }; + static props = {}; + setup() { + super.setup(); + const debugContext = useEnvDebugContext(); + this.command = useService("command"); + useCommand( + _t("Debug tools..."), + async () => { + const items = await debugContext.getItems(this.env); + let index = 0; + const defaultCategories = items + .filter((item) => item.type === "separator") + .map(() => (index += 1)); + const provider = { + async provide() { + const categories = [...defaultCategories]; + let category = categories.shift(); + const result = []; + items.forEach((item) => { + if (item.type === "item") { + result.push({ + name: item.description.toString(), + action: item.callback, + category, + }); + } else if (item.type === "separator") { + category = categories.shift(); + } + }); + return result; + }, + }; + const configByNamespace = { + default: { + categories: defaultCategories, + emptyMessage: _t("No debug command found"), + placeholder: _t("Choose a debug command..."), + }, + }; + const commandPaletteConfig = { + configByNamespace, + providers: [provider], + }; + return commandPaletteConfig; + }, + { + category: "debug", + } + ); + } +} diff --git a/frontend/web/static/src/core/debug/debug_menu.scss b/frontend/web/static/src/core/debug/debug_menu.scss new file mode 100644 index 0000000..073858f --- /dev/null +++ b/frontend/web/static/src/core/debug/debug_menu.scss @@ -0,0 +1,6 @@ +.o_dialog { + .o_debug_manager .dropdown-toggle { + padding: 0 4px; + margin: 2px 10px 2px 0; + } +} diff --git a/frontend/web/static/src/core/debug/debug_menu.xml b/frontend/web/static/src/core/debug/debug_menu.xml new file mode 100644 index 0000000..b533c2d --- /dev/null +++ b/frontend/web/static/src/core/debug/debug_menu.xml @@ -0,0 +1,34 @@ + + + + +
+ + + + + + + + + + + + + + +
+
+ +
diff --git a/frontend/web/static/src/core/debug/debug_menu_basic.js b/frontend/web/static/src/core/debug/debug_menu_basic.js new file mode 100644 index 0000000..4d61a72 --- /dev/null +++ b/frontend/web/static/src/core/debug/debug_menu_basic.js @@ -0,0 +1,44 @@ +import { useEnvDebugContext } from "./debug_context"; +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; +import { _t } from "@web/core/l10n/translation"; +import { groupBy, sortBy } from "@web/core/utils/arrays"; + +import { Component } from "@odoo/owl"; +import { registry } from "@web/core/registry"; + +const debugSectionRegistry = registry.category("debug_section"); + +debugSectionRegistry + .add("record", { label: _t("Record"), sequence: 10 }) + .add("records", { label: _t("Records"), sequence: 10 }) + .add("ui", { label: _t("User Interface"), sequence: 20 }) + .add("security", { label: _t("Security"), sequence: 30 }) + .add("testing", { label: _t("Tours & Testing"), sequence: 40 }) + .add("tools", { label: _t("Tools"), sequence: 50 }); + +export class DebugMenuBasic extends Component { + static template = "web.DebugMenu"; + static components = { + Dropdown, + DropdownItem, + }; + static props = {}; + + setup() { + this.debugContext = useEnvDebugContext(); + } + + async loadGroupedItems() { + const items = await this.debugContext.getItems(this.env); + const sections = groupBy(items, (item) => item.section || ""); + this.sectionEntries = sortBy( + Object.entries(sections), + ([section]) => debugSectionRegistry.get(section, { sequence: 50 }).sequence + ); + } + + getSectionLabel(section) { + return debugSectionRegistry.get(section, { label: section }).label; + } +} diff --git a/frontend/web/static/src/core/debug/debug_menu_items.js b/frontend/web/static/src/core/debug/debug_menu_items.js new file mode 100644 index 0000000..a6b6bdb --- /dev/null +++ b/frontend/web/static/src/core/debug/debug_menu_items.js @@ -0,0 +1,70 @@ +import { _t } from "@web/core/l10n/translation"; +import { browser } from "@web/core/browser/browser"; +import { router } from "@web/core/browser/router"; +import { registry } from "@web/core/registry"; +import { user } from "@web/core/user"; + +function activateTestsAssetsDebugging({ env }) { + if (String(router.current.debug).includes("tests")) { + return; + } + + return { + type: "item", + description: _t("Activate Test Mode"), + callback: () => { + router.pushState({ debug: "assets,tests" }, { reload: true }); + }, + sequence: 580, + section: "tools", + }; +} + +export function regenerateAssets({ env }) { + return { + type: "item", + description: _t("Regenerate Assets"), + callback: async () => { + await env.services.orm.call("ir.attachment", "regenerate_assets_bundles"); + browser.location.reload(); + }, + sequence: 550, + section: "tools", + }; +} + +export function becomeSuperuser({ env }) { + const becomeSuperuserURL = browser.location.origin + "/web/become"; + if (!user.isAdmin) { + return false; + } + return { + type: "item", + description: _t("Become Superuser"), + href: becomeSuperuserURL, + callback: () => { + browser.open(becomeSuperuserURL, "_self"); + }, + sequence: 560, + section: "tools", + }; +} + +function leaveDebugMode() { + return { + type: "item", + description: _t("Leave Debug Mode"), + callback: () => { + router.pushState({ debug: 0 }, { reload: true }); + }, + sequence: 650, + }; +} + +registry + .category("debug") + .category("default") + .add("regenerateAssets", regenerateAssets) + .add("becomeSuperuser", becomeSuperuser) + .add("activateTestsAssetsDebugging", activateTestsAssetsDebugging) + .add("leaveDebugMode", leaveDebugMode); diff --git a/frontend/web/static/src/core/debug/debug_menu_items.xml b/frontend/web/static/src/core/debug/debug_menu_items.xml new file mode 100644 index 0000000..07a71a4 --- /dev/null +++ b/frontend/web/static/src/core/debug/debug_menu_items.xml @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + +
+ + + +
+ + + +
+ + +
+ + +
+ + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID:
XML ID: + + +
+ + + + + / (create) + +
No Update: + + + (change) + +
Creation User:
Creation Date:
Latest Modification by:
Latest Modification Date:
+
+
+ + + +
+            
+                
+            
+        
+
+
diff --git a/frontend/web/static/src/core/debug/debug_providers.js b/frontend/web/static/src/core/debug/debug_providers.js new file mode 100644 index 0000000..09589df --- /dev/null +++ b/frontend/web/static/src/core/debug/debug_providers.js @@ -0,0 +1,56 @@ +import { _t } from "@web/core/l10n/translation"; +import { registry } from "../registry"; +import { browser } from "../browser/browser"; +import { router } from "../browser/router"; + +const commandProviderRegistry = registry.category("command_provider"); + +commandProviderRegistry.add("debug", { + provide: (env, options) => { + const result = []; + if (env.debug) { + if (!env.debug.includes("assets")) { + result.push({ + action() { + router.pushState({ debug: "assets" }, { reload: true }); + }, + category: "debug", + name: _t("Activate debug mode (with assets)"), + }); + } + result.push({ + action() { + router.pushState({ debug: 0 }, { reload: true }); + }, + category: "debug", + name: _t("Deactivate debug mode"), + }); + result.push({ + action() { + browser.open("/web/tests?debug=assets"); + }, + category: "debug", + name: _t("Run Unit Tests"), + }); + } else { + const debugKey = "debug"; + if (options.searchValue.toLowerCase() === debugKey) { + result.push({ + action() { + router.pushState({ debug: "1" }, { reload: true }); + }, + category: "debug", + name: `${_t("Activate debug mode")} (${debugKey})`, + }); + result.push({ + action() { + router.pushState({ debug: "assets" }, { reload: true }); + }, + category: "debug", + name: `${_t("Activate debug mode (with assets)")} (${debugKey})`, + }); + } + } + return result; + }, +}); diff --git a/frontend/web/static/src/core/debug/debug_utils.js b/frontend/web/static/src/core/debug/debug_utils.js new file mode 100644 index 0000000..d62e86a --- /dev/null +++ b/frontend/web/static/src/core/debug/debug_utils.js @@ -0,0 +1,11 @@ +export function editModelDebug(env, title, model, id) { + return env.services.action.doAction({ + res_model: model, + res_id: id, + name: title, + type: "ir.actions.act_window", + views: [[false, "form"]], + view_mode: "form", + target: "current", + }); +} diff --git a/frontend/web/static/src/core/dialog/dialog.js b/frontend/web/static/src/core/dialog/dialog.js new file mode 100644 index 0000000..209c6f1 --- /dev/null +++ b/frontend/web/static/src/core/dialog/dialog.js @@ -0,0 +1,145 @@ +import { useHotkey } from "@web/core/hotkeys/hotkey_hook"; +import { useActiveElement } from "../ui/ui_service"; +import { useForwardRefToParent } from "@web/core/utils/hooks"; +import { Component, onWillDestroy, useChildSubEnv, useExternalListener, useState } from "@odoo/owl"; +import { throttleForAnimation } from "@web/core/utils/timing"; +import { makeDraggableHook } from "../utils/draggable_hook_builder_owl"; + +const useDialogDraggable = makeDraggableHook({ + name: "useDialogDraggable", + onWillStartDrag({ ctx, addCleanup, addStyle, getRect }) { + const { height, width } = getRect(ctx.current.element); + ctx.current.container = document.createElement("div"); + addStyle(ctx.current.container, { + position: "fixed", + top: "0", + bottom: `${70 - height}px`, + left: `${70 - width}px`, + right: `${70 - width}px`, + }); + ctx.current.element.after(ctx.current.container); + addCleanup(() => ctx.current.container.remove()); + }, + onDrop({ ctx, getRect }) { + const { top, left } = getRect(ctx.current.element); + return { + left: left - ctx.current.elementRect.left, + top: top - ctx.current.elementRect.top, + }; + }, +}); + +export class Dialog extends Component { + static template = "web.Dialog"; + static props = { + contentClass: { type: String, optional: true }, + bodyClass: { type: String, optional: true }, + fullscreen: { type: Boolean, optional: true }, + footer: { type: Boolean, optional: true }, + header: { type: Boolean, optional: true }, + size: { + type: String, + optional: true, + validate: (s) => ["sm", "md", "lg", "xl", "fs", "fullscreen"].includes(s), + }, + technical: { type: Boolean, optional: true }, + title: { type: String, optional: true }, + modalRef: { type: Function, optional: true }, + slots: { + type: Object, + shape: { + default: Object, // Content is not optional + header: { type: Object, optional: true }, + footer: { type: Object, optional: true }, + }, + }, + withBodyPadding: { type: Boolean, optional: true }, + onExpand: { type: Function, optional: true }, + }; + static defaultProps = { + contentClass: "", + bodyClass: "", + fullscreen: false, + footer: true, + header: true, + size: "lg", + technical: true, + title: "Odoo", + withBodyPadding: true, + }; + + setup() { + this.modalRef = useForwardRefToParent("modalRef"); + useActiveElement("modalRef"); + this.data = useState(this.env.dialogData); + useHotkey("escape", () => this.onEscape()); + useHotkey( + "control+enter", + () => { + const btns = document.querySelectorAll( + ".o_dialog:not(.o_inactive_modal) .modal-footer button" + ); + const firstVisibleBtn = Array.from(btns).find((btn) => { + const styles = getComputedStyle(btn); + return styles.display !== "none"; + }); + if (firstVisibleBtn) { + firstVisibleBtn.click(); + } + }, + { bypassEditableProtection: true } + ); + this.id = `dialog_${this.data.id}`; + useChildSubEnv({ inDialog: true, dialogId: this.id }); + this.isMovable = this.props.header; + if (this.isMovable) { + this.position = useState({ left: 0, top: 0 }); + useDialogDraggable({ + enable: () => !this.env.isSmall, + ref: this.modalRef, + elements: ".modal-content", + handle: ".modal-header", + ignore: "button, input", + edgeScrolling: { enabled: false }, + onDrop: ({ top, left }) => { + this.position.left += left; + this.position.top += top; + }, + }); + const throttledResize = throttleForAnimation(this.onResize.bind(this)); + useExternalListener(window, "resize", throttledResize); + } + onWillDestroy(() => { + if (this.env.isSmall) { + this.data.scrollToOrigin(); + } + }); + } + + get isFullscreen() { + return this.props.fullscreen || this.env.isSmall; + } + + get contentStyle() { + if (this.isMovable) { + return `top: ${this.position.top}px; left: ${this.position.left}px;`; + } + return ""; + } + + onResize() { + this.position.left = 0; + this.position.top = 0; + } + + onEscape() { + return this.dismiss(); + } + + async dismiss() { + if (this.data.dismiss) { + await this.data.dismiss(); + } + return this.data.close({ dismiss: true }); + } +} diff --git a/frontend/web/static/src/core/dialog/dialog.scss b/frontend/web/static/src/core/dialog/dialog.scss new file mode 100644 index 0000000..5262856 --- /dev/null +++ b/frontend/web/static/src/core/dialog/dialog.scss @@ -0,0 +1,82 @@ +.modal.o_technical_modal { + .modal-content { + .modal-header .modal-title { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + .modal-footer { + text-align: left; + + button { + margin: 0; // Reset boostrap. + } + + button.o-default-button:not(:only-child) { + display: none; + } + + @include media-breakpoint-down(md) { + .btn { + width: 45%; + text-overflow: ellipsis; + white-space: inherit; + } + } + + } + + @include media-breakpoint-down(sm) { + &.o_modal_full { + .modal-dialog { + margin: 0px; + height: 100%; + + .modal-content { + height: 100%; + border: none; + + .modal-body { + height: 100%; + overflow-y: auto; + } + } + } + } + } +} + +.modal.o_inactive_modal { + z-index: $zindex-modal-backdrop - 1; +} + +.o_dialog > .modal { + display: block; +} + +@include media-breakpoint-up(sm) { + .modal-fs { + width: calc(100% - #{2 * $modal-dialog-margin-y-sm-up}); + max-width: none; + } +} + +@include media-breakpoint-down(md) { + .modal { + &.o_modal_full .modal-content { + .modal-header { + align-items: center; + height: $o-navbar-height; + padding: 0 1rem; + } + + .modal-footer { + @include o-webclient-padding($top: 1rem, $bottom: 0.5rem); + box-shadow: 0 1rem 2rem black; + z-index: 0; + } + } + } +} diff --git a/frontend/web/static/src/core/dialog/dialog.xml b/frontend/web/static/src/core/dialog/dialog.xml new file mode 100644 index 0000000..b032e79 --- /dev/null +++ b/frontend/web/static/src/core/dialog/dialog.xml @@ -0,0 +1,47 @@ + + + + +
+ +
+
+ + + + + + +
diff --git a/frontend/web/static/src/core/dialog/dialog_service.js b/frontend/web/static/src/core/dialog/dialog_service.js new file mode 100644 index 0000000..8dc913d --- /dev/null +++ b/frontend/web/static/src/core/dialog/dialog_service.js @@ -0,0 +1,103 @@ +import { Component, markRaw, reactive, useChildSubEnv, xml } from "@odoo/owl"; +import { registry } from "@web/core/registry"; + +class DialogWrapper extends Component { + static template = xml``; + static props = ["*"]; + setup() { + useChildSubEnv({ dialogData: this.props.subEnv }); + } +} + +/** + * @typedef {{ + * onClose?(): void; + * }} DialogServiceInterfaceAddOptions + */ +/** + * @typedef {{ + * add( + * Component: typeof import("@odoo/owl").Component, + * props: {}, + * options?: DialogServiceInterfaceAddOptions + * ): () => void; + * }} DialogServiceInterface + */ + +export const dialogService = { + dependencies: ["overlay"], + /** @returns {DialogServiceInterface} */ + start(env, { overlay }) { + const stack = []; + let nextId = 0; + + const deactivate = () => { + for (const subEnv of stack) { + subEnv.isActive = false; + } + }; + + const add = (dialogClass, props, options = {}) => { + const id = nextId++; + const close = (params) => remove(params); + const subEnv = reactive({ + id, + close, + isActive: true, + }); + + deactivate(); + stack.push(subEnv); + document.body.classList.add("modal-open"); + let isBeingClosed = false; + + const scrollOrigin = { top: window.scrollY, left: window.scrollX }; + subEnv.scrollToOrigin = () => { + if (!stack.length) { + window.scrollTo(scrollOrigin); + } + }; + + const remove = overlay.add( + DialogWrapper, + { + subComponent: dialogClass, + subProps: markRaw({ ...props, close }), + subEnv, + }, + { + onRemove: async (closeParams) => { + if (isBeingClosed) { + return; + } + isBeingClosed = true; + await options.onClose?.(closeParams); + stack.splice( + stack.findIndex((d) => d.id === id), + 1 + ); + deactivate(); + if (stack.length) { + stack.at(-1).isActive = true; + } else { + document.body.classList.remove("modal-open"); + } + }, + rootId: options.context?.root?.el?.getRootNode()?.host?.id, + } + ); + + return remove; + }; + + function closeAll(params) { + for (const dialog of [...stack].reverse()) { + dialog.close(params); + } + } + + return { add, closeAll }; + }, +}; + +registry.category("services").add("dialog", dialogService); diff --git a/frontend/web/static/src/core/domain.js b/frontend/web/static/src/core/domain.js new file mode 100644 index 0000000..36bb493 --- /dev/null +++ b/frontend/web/static/src/core/domain.js @@ -0,0 +1,426 @@ +import { shallowEqual } from "@web/core/utils/arrays"; +import { evaluate, formatAST, parseExpr } from "./py_js/py"; +import { toPyValue } from "./py_js/py_utils"; +import { escapeRegExp } from "@web/core/utils/strings"; + +/** + * @typedef {import("./py_js/py_parser").AST} AST + * @typedef {[string | 0 | 1, string, any]} Condition + * @typedef {("&" | "|" | "!" | Condition)[]} DomainListRepr + * @typedef {DomainListRepr | string | Domain} DomainRepr + */ + +export class InvalidDomainError extends Error {} + +/** + * Javascript representation of an Odoo domain + */ +export class Domain { + /** + * Combine various domains together with a given operator + * @param {DomainRepr[]} domains + * @param {"AND" | "OR"} operator + * @returns {Domain} + */ + static combine(domains, operator) { + if (domains.length === 0) { + return new Domain([]); + } + const domain1 = domains[0] instanceof Domain ? domains[0] : new Domain(domains[0]); + if (domains.length === 1) { + return domain1; + } + const domain2 = Domain.combine(domains.slice(1), operator); + const result = new Domain([]); + const astValues1 = domain1.ast.value; + const astValues2 = domain2.ast.value; + const op = operator === "AND" ? "&" : "|"; + const combinedAST = { type: 4 /* List */, value: astValues1.concat(astValues2) }; + result.ast = normalizeDomainAST(combinedAST, op); + return result; + } + + /** + * Combine various domains together with `AND` operator + * @param {DomainRepr[]} domains + * @returns {Domain} + */ + static and(domains) { + return Domain.combine(domains, "AND"); + } + + /** + * Combine various domains together with `OR` operator + * @param {DomainRepr[]} domains + * @returns {Domain} + */ + static or(domains) { + return Domain.combine(domains, "OR"); + } + + /** + * Return the negation of the domain + * @returns {Domain} + */ + static not(domain) { + const result = new Domain(domain); + result.ast.value.unshift({ type: 1, value: "!" }); + return result; + } + + /** + * Return a new domain with `neutralized` leaves (for the leaves that are applied on the field that are part of + * keysToRemove). + * @param {DomainRepr} domain + * @param {string[]} keysToRemove + * @return {Domain} + */ + static removeDomainLeaves(domain, keysToRemove) { + function processLeaf(elements, idx, operatorCtx, newDomain) { + const leaf = elements[idx]; + if (leaf.type === 10) { + if (keysToRemove.includes(leaf.value[0].value)) { + if (operatorCtx === "&") { + newDomain.ast.value.push(...Domain.TRUE.ast.value); + } else if (operatorCtx === "|") { + newDomain.ast.value.push(...Domain.FALSE.ast.value); + } + } else { + newDomain.ast.value.push(leaf); + } + return 1; + } else if (leaf.type === 1) { + // Special case to avoid OR ('|') that can never resolve to true + if ( + leaf.value === "|" && + elements[idx + 1].type === 10 && + elements[idx + 2].type === 10 && + keysToRemove.includes(elements[idx + 1].value[0].value) && + keysToRemove.includes(elements[idx + 2].value[0].value) + ) { + newDomain.ast.value.push(...Domain.TRUE.ast.value); + return 3; + } + newDomain.ast.value.push(leaf); + if (leaf.value === "!") { + return 1 + processLeaf(elements, idx + 1, "&", newDomain); + } + const firstLeafSkip = processLeaf(elements, idx + 1, leaf.value, newDomain); + const secondLeafSkip = processLeaf( + elements, + idx + 1 + firstLeafSkip, + leaf.value, + newDomain + ); + return 1 + firstLeafSkip + secondLeafSkip; + } + return 0; + } + + domain = new Domain(domain); + if (domain.ast.value.length === 0) { + return domain; + } + const newDomain = new Domain([]); + processLeaf(domain.ast.value, 0, "&", newDomain); + return newDomain; + } + + /** + * @param {DomainRepr} [descr] + */ + constructor(descr = []) { + if (descr instanceof Domain) { + /** @type {AST} */ + return new Domain(descr.toString()); + } else { + let rawAST; + try { + rawAST = typeof descr === "string" ? parseExpr(descr) : toAST(descr); + } catch (error) { + throw new InvalidDomainError(`Invalid domain representation: ${descr.toString()}`, { + cause: error, + }); + } + this.ast = normalizeDomainAST(rawAST); + } + } + + /** + * Check if the set of records represented by a domain contains a record + * Warning: smart dates (see parseSmartDateInput) are not handled here. + * + * @param {Object} record + * @returns {boolean} + */ + contains(record) { + const expr = evaluate(this.ast, record); + return matchDomain(record, expr); + } + + /** + * @returns {string} + */ + toString() { + return formatAST(this.ast); + } + + /** + * @param {Object} context + * @returns {DomainListRepr} + */ + toList(context) { + return evaluate(this.ast, context); + } + + /** + * Converts the domain into a human-readable format for JSON representation. + * If the domain does not contain any contextual value, it is converted to a list. + * Otherwise, it is returned as a string. + * + * The string format is less readable due to escaped double quotes. + * Example: "[\"&\",[\"user_id\",\"=\",uid],[\"team_id\",\"!=\",false]]" + * @returns {DomainListRepr | string} + */ + toJson() { + try { + // Attempt to evaluate the domain without context + const evaluatedAsList = this.toList({}); + const evaluatedDomain = new Domain(evaluatedAsList); + if (evaluatedDomain.toString() === this.toString()) { + return evaluatedAsList; + } + return this.toString(); + } catch { + // The domain couldn't be evaluated due to contextual values + return this.toString(); + } + } +} + +/** @type {Condition} */ +const TRUE_LEAF = [1, "=", 1]; +/** @type {Condition} */ +const FALSE_LEAF = [0, "=", 1]; +const TRUE_DOMAIN = new Domain([TRUE_LEAF]); +const FALSE_DOMAIN = new Domain([FALSE_LEAF]); + +Domain.TRUE = TRUE_DOMAIN; +Domain.FALSE = FALSE_DOMAIN; + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- + +/** + * @param {DomainListRepr} domain + * @returns {AST} + */ +function toAST(domain) { + const elems = domain.map((elem) => { + switch (elem) { + case "!": + case "&": + case "|": + return { type: 1 /* String */, value: elem }; + default: + return { + type: 10 /* Tuple */, + value: elem.map(toPyValue), + }; + } + }); + return { type: 4 /* List */, value: elems }; +} + +/** + * Normalizes a domain + * + * @param {AST} domain + * @param {'&' | '|'} [op] + * @returns {AST} + */ + +function normalizeDomainAST(domain, op = "&") { + if (domain.type !== 4 /* List */) { + if (domain.type === 10 /* Tuple */) { + const value = domain.value; + /* Tuple contains at least one Tuple and optionally string */ + if ( + value.findIndex((e) => e.type === 10) === -1 || + !value.every((e) => e.type === 10 || e.type === 1) + ) { + throw new InvalidDomainError("Invalid domain AST"); + } + } else { + throw new InvalidDomainError("Invalid domain AST"); + } + } + if (domain.value.length === 0) { + return domain; + } + let expected = 1; + for (const child of domain.value) { + switch (child.type) { + case 1 /* String */: + if (child.value === "&" || child.value === "|") { + expected++; + } else if (child.value !== "!") { + throw new InvalidDomainError("Invalid domain AST"); + } + break; + case 4: /* list */ + case 10 /* tuple */: + if (child.value.length === 3) { + expected--; + break; + } + throw new InvalidDomainError("Invalid domain AST"); + default: + throw new InvalidDomainError("Invalid domain AST"); + } + } + const values = domain.value.slice(); + while (expected < 0) { + expected++; + values.unshift({ type: 1 /* String */, value: op }); + } + if (expected > 0) { + throw new InvalidDomainError( + `invalid domain ${formatAST(domain)} (missing ${expected} segment(s))` + ); + } + return { type: 4 /* List */, value: values }; +} + +/** + * @param {Object} record + * @param {Condition | boolean} condition + * @returns {boolean} + */ +function matchCondition(record, condition) { + if (typeof condition === "boolean") { + return condition; + } + const [field, operator, value] = condition; + + if (typeof field === "string") { + const names = field.split("."); + if (names.length >= 2) { + return matchCondition(record[names[0]], [names.slice(1).join("."), operator, value]); + } + } + let likeRegexp, ilikeRegexp; + if (["like", "not like", "ilike", "not ilike"].includes(operator)) { + likeRegexp = new RegExp(`(.*)${escapeRegExp(value).replaceAll("%", "(.*)")}(.*)`, "g"); + ilikeRegexp = new RegExp(`(.*)${escapeRegExp(value).replaceAll("%", "(.*)")}(.*)`, "gi"); + } + const fieldValue = typeof field === "number" ? field : record[field]; + const isNot = operator.startsWith("not "); + switch (operator) { + case "=?": + if ([false, null].includes(value)) { + return true; + } + // eslint-disable-next-line no-fallthrough + case "=": + case "==": + if (Array.isArray(fieldValue) && Array.isArray(value)) { + return shallowEqual(fieldValue, value); + } + return fieldValue === value; + case "!=": + case "<>": + return !matchCondition(record, [field, "=", value]); + case "<": + return fieldValue < value; + case "<=": + return fieldValue <= value; + case ">": + return fieldValue > value; + case ">=": + return fieldValue >= value; + case "in": + case "not in": { + const val = Array.isArray(value) ? value : [value]; + const fieldVal = Array.isArray(fieldValue) ? fieldValue : [fieldValue]; + return Boolean(fieldVal.some((fv) => val.includes(fv))) != isNot; + } + case "like": + case "not like": + if (fieldValue === false) { + return isNot; + } + return Boolean(fieldValue.match(likeRegexp)) != isNot; + case "=like": + case "not =like": + if (fieldValue === false) { + return isNot; + } + return ( + Boolean(new RegExp(escapeRegExp(value).replace(/%/g, ".*")).test(fieldValue)) != + isNot + ); + case "ilike": + case "not ilike": + if (fieldValue === false) { + return isNot; + } + return Boolean(fieldValue.match(ilikeRegexp)) != isNot; + case "=ilike": + case "not =ilike": + if (fieldValue === false) { + return isNot; + } + return ( + Boolean( + new RegExp(escapeRegExp(value).replace(/%/g, ".*"), "i").test(fieldValue) + ) != isNot + ); + case "any": + case "not any": + return true; + case "child_of": + case "parent_of": + return true; + } + throw new InvalidDomainError("could not match domain"); +} + +/** + * @param {Object} record + * @returns {Object} + */ +function makeOperators(record) { + const match = matchCondition.bind(null, record); + return { + "!": (x) => !match(x), + "&": (a, b) => match(a) && match(b), + "|": (a, b) => match(a) || match(b), + }; +} + +/** + * + * @param {Object} record + * @param {DomainListRepr} domain + * @returns {boolean} + */ +function matchDomain(record, domain) { + if (domain.length === 0) { + return true; + } + const operators = makeOperators(record); + const reversedDomain = Array.from(domain).reverse(); + const condStack = []; + for (const item of reversedDomain) { + const operator = typeof item === "string" && operators[item]; + if (operator) { + const operands = condStack.splice(-operator.length); + condStack.push(operator(...operands)); + } else { + condStack.push(item); + } + } + return matchCondition(record, condStack.pop()); +} diff --git a/frontend/web/static/src/core/domain_selector/domain_selector.js b/frontend/web/static/src/core/domain_selector/domain_selector.js new file mode 100644 index 0000000..9479749 --- /dev/null +++ b/frontend/web/static/src/core/domain_selector/domain_selector.js @@ -0,0 +1,156 @@ +import { Component, onWillStart, onWillUpdateProps } from "@odoo/owl"; +import { CheckBox } from "@web/core/checkbox/checkbox"; +import { Domain } from "@web/core/domain"; +import { getDomainDisplayedOperators } from "@web/core/domain_selector/domain_selector_operator_editor"; +import { _t } from "@web/core/l10n/translation"; +import { ModelFieldSelector } from "@web/core/model_field_selector/model_field_selector"; +import { + areEqualTrees, + condition, + connector, + formatValue, +} from "@web/core/tree_editor/condition_tree"; +import { domainFromTree } from "@web/core/tree_editor/domain_from_tree"; +import { TreeEditor } from "@web/core/tree_editor/tree_editor"; +import { getOperatorEditorInfo } from "@web/core/tree_editor/tree_editor_operator_editor"; +import { useService } from "@web/core/utils/hooks"; +import { getDefaultCondition } from "./utils"; + +const ARCHIVED_CONDITION = condition("active", "in", [true, false]); +const ARCHIVED_DOMAIN = `[("active", "in", [True, False])]`; + +export class DomainSelector extends Component { + static template = "web.DomainSelector"; + static components = { TreeEditor, CheckBox }; + static props = { + domain: String, + resModel: String, + className: { type: String, optional: true }, + defaultConnector: { type: [{ value: "&" }, { value: "|" }], optional: true }, + isDebugMode: { type: Boolean, optional: true }, + readonly: { type: Boolean, optional: true }, + update: { type: Function, optional: true }, + debugUpdate: { type: Function, optional: true }, + }; + static defaultProps = { + isDebugMode: false, + readonly: true, + update: () => {}, + }; + + setup() { + this.fieldService = useService("field"); + this.treeProcessor = useService("tree_processor"); + + this.tree = null; + this.showArchivedCheckbox = false; + this.includeArchived = false; + + onWillStart(() => this.onPropsUpdated(this.props)); + onWillUpdateProps((np) => this.onPropsUpdated(np)); + } + + async onPropsUpdated(p) { + let domain; + let isSupported = true; + try { + domain = new Domain(p.domain); + } catch { + isSupported = false; + } + if (!isSupported) { + this.tree = null; + this.showArchivedCheckbox = false; + this.includeArchived = false; + return; + } + + const [tree, { fieldDef: activeFieldDef }] = await Promise.all([ + this.treeProcessor.treeFromDomain(p.resModel, domain, !p.isDebugMode), + this.fieldService.loadFieldInfo(p.resModel, "active"), + ]); + + this.tree = tree; + this.showArchivedCheckbox = this.getShowArchivedCheckBox(Boolean(activeFieldDef), p); + + this.includeArchived = false; + if (this.showArchivedCheckbox) { + if (this.tree.type === "connector" && this.tree.value === "&") { + this.tree.children = this.tree.children.filter((child) => { + if (areEqualTrees(child, ARCHIVED_CONDITION)) { + this.includeArchived = true; + return false; + } + return true; + }); + if (this.tree.children.length === 1) { + this.tree = this.tree.children[0]; + } + } else if (areEqualTrees(this.tree, ARCHIVED_CONDITION)) { + this.includeArchived = true; + this.tree = connector("&"); + } + } + } + + getShowArchivedCheckBox(hasActiveField, props) { + return hasActiveField; + } + + getDefaultCondition(fieldDefs) { + return getDefaultCondition(fieldDefs); + } + + getDefaultOperator(fieldDef) { + return getDomainDisplayedOperators(fieldDef)[0]; + } + + getOperatorEditorInfo(fieldDef) { + const operators = getDomainDisplayedOperators(fieldDef); + return getOperatorEditorInfo(operators, fieldDef); + } + + getPathEditorInfo(resModel, defaultCondition) { + const { isDebugMode } = this.props; + return { + component: ModelFieldSelector, + extractProps: ({ update, value: path }) => ({ + path, + update, + resModel, + isDebugMode, + readonly: false, + }), + isSupported: (path) => [0, 1].includes(path) || typeof path === "string", + defaultValue: () => defaultCondition.path, + stringify: (path) => formatValue(path), + message: _t("Invalid field chain"), + }; + } + + toggleIncludeArchived() { + this.includeArchived = !this.includeArchived; + this.update(this.tree); + } + + resetDomain() { + this.props.update("[]"); + } + + onDomainInput(domain) { + if (this.props.debugUpdate) { + this.props.debugUpdate(domain); + } + } + + onDomainChange(domain) { + this.props.update(domain, true); + } + update(tree) { + const archiveDomain = this.includeArchived ? ARCHIVED_DOMAIN : `[]`; + const domain = tree + ? Domain.and([domainFromTree(tree), archiveDomain]).toString() + : archiveDomain; + this.props.update(domain); + } +} diff --git a/frontend/web/static/src/core/domain_selector/domain_selector.xml b/frontend/web/static/src/core/domain_selector/domain_selector.xml new file mode 100644 index 0000000..347443e --- /dev/null +++ b/frontend/web/static/src/core/domain_selector/domain_selector.xml @@ -0,0 +1,46 @@ + + + + +
+ + + + Include archived + + + + +
+ This domain is not supported. + + + +
+
+ +