diff --git a/web_filter_paste_multiline/README.rst b/web_filter_paste_multiline/README.rst new file mode 100644 index 00000000000..3c4734e6c8a --- /dev/null +++ b/web_filter_paste_multiline/README.rst @@ -0,0 +1,99 @@ +====================== +Filter Multiline Paste +====================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:16573d04b96295a67f9bf208eebad571aab48466016aa1ede81120ae5c610078 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fweb-lightgray.png?logo=github + :target: https://github.com/OCA/web/tree/18.0/web_filter_paste_multiline + :alt: OCA/web +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-18-0/web-18-0-web_filter_paste_multiline + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/web&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +When using the **Add Custom Filter** dialog (Filters → Add Custom +Filter) with an **is in** or **is not in** operator, Odoo normally +inserts pasted text as a single value, even when the clipboard contains +multiple lines. + +|image1| + +When this module is installed, when a multi-line text is pasted in the +search value with the **is in** or **is not in** operator, it is +automatically expanded as one value per line, as if the user had typed +each value one by one. Each line becomes a separate tag in the filter: + +|image2| + +.. |image1| image:: https://raw.githubusercontent.com/OCA/web/18.0/web_filter_paste_multiline/static/description/paste_before.png +.. |image2| image:: https://raw.githubusercontent.com/OCA/web/18.0/web_filter_paste_multiline/static/description/paste_after.png + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Open a list view and go to **Filters → Add Custom Filter**. Pick any +field, set the operator to **is in** (or **is not in**), then paste a +list of values copied from a spreadsheet, a text file, or any other +source that produces one value per line. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* QoQa + +Contributors +------------ + +- Guewen Baconnier + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/web `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/web_filter_paste_multiline/__init__.py b/web_filter_paste_multiline/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/web_filter_paste_multiline/__manifest__.py b/web_filter_paste_multiline/__manifest__.py new file mode 100644 index 00000000000..3d2ad93c2a4 --- /dev/null +++ b/web_filter_paste_multiline/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2026 QoQa Services SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html) + +{ + "name": "Filter Multiline Paste", + "category": "Hidden/Tools", + "version": "18.0.1.0.0", + "license": "LGPL-3", + "author": "QoQa, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/web", + "depends": ["web"], + "installable": True, + "assets": { + "web.assets_backend": [ + "web_filter_paste_multiline/static/src/list_paste_multiline.esm.js", + "web_filter_paste_multiline/static/src/list_paste_multiline.xml", + ], + "web.assets_unit_tests": [ + "web_filter_paste_multiline/static/tests/*", + ], + }, +} diff --git a/web_filter_paste_multiline/pyproject.toml b/web_filter_paste_multiline/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/web_filter_paste_multiline/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/web_filter_paste_multiline/readme/CONTRIBUTORS.md b/web_filter_paste_multiline/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..8ccdab60e46 --- /dev/null +++ b/web_filter_paste_multiline/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Guewen Baconnier \<\> diff --git a/web_filter_paste_multiline/readme/DESCRIPTION.md b/web_filter_paste_multiline/readme/DESCRIPTION.md new file mode 100644 index 00000000000..7f5f8f9e9ac --- /dev/null +++ b/web_filter_paste_multiline/readme/DESCRIPTION.md @@ -0,0 +1,12 @@ +When using the **Add Custom Filter** dialog (Filters → Add Custom Filter) +with an **is in** or **is not in** operator, Odoo normally inserts pasted +text as a single value, even when the clipboard contains multiple lines. + +![](../static/description/paste_before.png) + +When this module is installed, when a multi-line text is pasted in the +search value with the **is in** or **is not in** operator, it is +automatically expanded as one value per line, as if the user had typed +each value one by one. Each line becomes a separate tag in the filter: + +![](../static/description/paste_after.png) diff --git a/web_filter_paste_multiline/readme/USAGE.md b/web_filter_paste_multiline/readme/USAGE.md new file mode 100644 index 00000000000..fac899ce407 --- /dev/null +++ b/web_filter_paste_multiline/readme/USAGE.md @@ -0,0 +1,4 @@ +Open a list view and go to **Filters → Add Custom Filter**. Pick any +field, set the operator to **is in** (or **is not in**), then paste a list +of values copied from a spreadsheet, a text file, or any other source that +produces one value per line. diff --git a/web_filter_paste_multiline/static/description/index.html b/web_filter_paste_multiline/static/description/index.html new file mode 100644 index 00000000000..eb3d5da0858 --- /dev/null +++ b/web_filter_paste_multiline/static/description/index.html @@ -0,0 +1,440 @@ + + + + + +Filter Multiline Paste + + + +
+

Filter Multiline Paste

+ + +

Beta License: LGPL-3 OCA/web Translate me on Weblate Try me on Runboat

+

When using the Add Custom Filter dialog (Filters → Add Custom +Filter) with an is in or is not in operator, Odoo normally +inserts pasted text as a single value, even when the clipboard contains +multiple lines.

+

image1

+

When this module is installed, when a multi-line text is pasted in the +search value with the is in or is not in operator, it is +automatically expanded as one value per line, as if the user had typed +each value one by one. Each line becomes a separate tag in the filter:

+

image2

+

Table of contents

+ +
+

Usage

+

Open a list view and go to Filters → Add Custom Filter. Pick any +field, set the operator to is in (or is not in), then paste a +list of values copied from a spreadsheet, a text file, or any other +source that produces one value per line.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • QoQa
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/web project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/web_filter_paste_multiline/static/description/paste_after.png b/web_filter_paste_multiline/static/description/paste_after.png new file mode 100644 index 00000000000..8bfe9c61751 Binary files /dev/null and b/web_filter_paste_multiline/static/description/paste_after.png differ diff --git a/web_filter_paste_multiline/static/description/paste_before.png b/web_filter_paste_multiline/static/description/paste_before.png new file mode 100644 index 00000000000..753aacad621 Binary files /dev/null and b/web_filter_paste_multiline/static/description/paste_before.png differ diff --git a/web_filter_paste_multiline/static/src/list_paste_multiline.esm.js b/web_filter_paste_multiline/static/src/list_paste_multiline.esm.js new file mode 100644 index 00000000000..760cf80e5e1 --- /dev/null +++ b/web_filter_paste_multiline/static/src/list_paste_multiline.esm.js @@ -0,0 +1,34 @@ +import {List} from "@web/core/tree_editor/tree_editor_components"; +import {patch} from "@web/core/utils/patch"; + +patch(List.prototype, { + /** + * When the user pastes multiline text into the input, split each non-empty + * line into a separate tag value. Single lines are handled normally. + */ + onPaste(ev) { + const pastedText = ev.clipboardData.getData("text/plain"); + if (!pastedText) return; + + const lines = pastedText + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((item) => item); + + // Single line is pasted, the input will handle it normally + if (lines.length <= 1) return; + + ev.preventDefault(); + ev.stopPropagation(); + + // Each line is created as a tag in the search + this.props.update([...this.props.value, ...lines]); + + // Clear the input field so the pasted text does not appear, as it + // is replaced by tags + const input = ev.currentTarget.querySelector("input"); + if (input) { + input.value = ""; + } + }, +}); diff --git a/web_filter_paste_multiline/static/src/list_paste_multiline.xml b/web_filter_paste_multiline/static/src/list_paste_multiline.xml new file mode 100644 index 00000000000..fcb9dba7a74 --- /dev/null +++ b/web_filter_paste_multiline/static/src/list_paste_multiline.xml @@ -0,0 +1,12 @@ + + + + + onPaste + + + diff --git a/web_filter_paste_multiline/static/tests/list_paste_multiline.test.js b/web_filter_paste_multiline/static/tests/list_paste_multiline.test.js new file mode 100644 index 00000000000..6152c248ea5 --- /dev/null +++ b/web_filter_paste_multiline/static/tests/list_paste_multiline.test.js @@ -0,0 +1,113 @@ +import { + SELECTORS, + openModelFieldSelectorPopover, + selectOperator, +} from "@web/../tests/core/tree_editor/condition_tree_editor_test_helpers"; +import { + contains, + defineModels, + fields, + models, + mountWithSearch, + openAddCustomFilterDialog, + toggleSearchBarMenu, +} from "@web/../tests/web_test_helpers"; +import {expect, test} from "@odoo/hoot"; +import {queryAllTexts, queryOne} from "@odoo/hoot-dom"; +import {SearchBarMenu} from "@web/search/search_bar_menu/search_bar_menu"; +import {animationFrame} from "@odoo/hoot-mock"; + +class Foo extends models.Model { + email = fields.Char({string: "Email"}); +} +defineModels([Foo]); + +/** + * Simulate a paste event on an element + * + * Note: works in chrome/chromium (headless or not) as is run by the odoo test + * suite, but fails in Firefox + */ +function pasteText(element, text) { + const clipboardData = new DataTransfer(); + clipboardData.setData("text/plain", text); + element.dispatchEvent( + new ClipboardEvent("paste", { + clipboardData, + bubbles: true, + }) + ); +} + +/** + * Open a custom filter on a field named email and with the In operator + */ +async function openFilterWithEmailField() { + await mountWithSearch(SearchBarMenu, { + resModel: "foo", + searchMenuTypes: ["filter"], + searchViewId: false, + searchViewArch: ``, + }); + await toggleSearchBarMenu(); + await openAddCustomFilterDialog(); + await openModelFieldSelectorPopover(); + await contains(".o_model_field_selector_popover_item_name:contains(Email)").click(); + await selectOperator("in"); +} + +test("paste multiline text creates one tag line", async () => { + await openFilterWithEmailField(); + + const input = queryOne(`${SELECTORS.valueEditor} input`); + pasteText(input, "a@example.com\nb@example.com\nc@example.com"); + await animationFrame(); + + expect(queryAllTexts(`${SELECTORS.valueEditor} .o_tag`)).toEqual([ + "a@example.com", + "b@example.com", + "c@example.com", + ]); +}); + +test("paste one line text inputs normal search value", async () => { + await openFilterWithEmailField(); + + const input = queryOne(`${SELECTORS.valueEditor} input`); + pasteText(input, "single@example.com"); + await animationFrame(); + + expect(queryAllTexts(`${SELECTORS.valueEditor} .o_tag`)).toEqual([]); +}); + +test("empty and only whitespace lines are ignored", async () => { + await openFilterWithEmailField(); + + const input = queryOne(`${SELECTORS.valueEditor} input`); + pasteText(input, "a@example.com\n \n\nb@example.com\n"); + await animationFrame(); + + expect(queryAllTexts(`${SELECTORS.valueEditor} .o_tag`)).toEqual([ + "a@example.com", + "b@example.com", + ]); +}); + +test("paste appends to already-existing tags", async () => { + await openFilterWithEmailField(); + + const input = queryOne(`${SELECTORS.valueEditor} input`); + + pasteText(input, "a@example.com\nb@example.com"); + await animationFrame(); + + pasteText(input, "c@example.com\nd@example.com"); + await animationFrame(); + + expect(queryAllTexts(`${SELECTORS.valueEditor} .o_tag`)).toEqual([ + "a@example.com", + "b@example.com", + "c@example.com", + "d@example.com", + ]); +}); diff --git a/web_filter_paste_multiline/tests/__init__.py b/web_filter_paste_multiline/tests/__init__.py new file mode 100644 index 00000000000..7ca6af2aa39 --- /dev/null +++ b/web_filter_paste_multiline/tests/__init__.py @@ -0,0 +1 @@ +from . import test_web_filter_paste_multiline diff --git a/web_filter_paste_multiline/tests/test_web_filter_paste_multiline.py b/web_filter_paste_multiline/tests/test_web_filter_paste_multiline.py new file mode 100644 index 00000000000..cc3c40731a5 --- /dev/null +++ b/web_filter_paste_multiline/tests/test_web_filter_paste_multiline.py @@ -0,0 +1,15 @@ +from odoo.tests import HttpCase, tagged + + +@tagged("post_install", "-at_install") +class TestWebFilterPasteMultiline(HttpCase): + def test_js(self): + self.browser_js( + "/web/tests?headless&loglevel=2&preset=desktop&timeout=15000&filter=web_filter_paste_multiline", + "", + "", + login="admin", + timeout=1800, + success_signal="[HOOT] Test suite succeeded", + error_checker=lambda x: "[HOOT]" not in x, + )