diff --git a/web_x2many_searchable/README.rst b/web_x2many_searchable/README.rst new file mode 100644 index 000000000000..1b1f1d0af7f0 --- /dev/null +++ b/web_x2many_searchable/README.rst @@ -0,0 +1,141 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +==================================== +X2Many inline search (form sub-list) +==================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:0c1077987b6fce5a154562c21b2c4871f7820c289b0297fb9647da56ef43b015 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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/19.0/web_x2many_searchable + :alt: OCA/web +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/web-19-0/web-19-0-web_x2many_searchable + :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=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module adds an optional **inline quick search** to x2many embedded +lists inside form views. + +It is activated per subview by adding ``searchable="1"`` on the embedded +```` node. + +Key behaviors: + +- Core-like UX with **facets (chips)**: typing does not create facets; + filtering is applied on **Enter**. +- An **Advanced** domain editor is provided via Odoo's domain selector + dialog. +- Works with both list implementations used by x2many in forms: + + - Dynamic lists: apply domains through ``list.load({domain})``. + - Static lists: emulate filtering by replacing x2many ids with the + result of a server-side ``search()``, constrained to the original + relation ids. + +Screenshots +----------- + +|Inline search input| + +|Applied search facet (chip)| + +|Advanced domain selector| + +.. |Inline search input| image:: https://raw.githubusercontent.com/OCA/web/19.0/web_x2many_searchable/static/description/01.png +.. |Applied search facet (chip)| image:: https://raw.githubusercontent.com/OCA/web/19.0/web_x2many_searchable/static/description/02.png +.. |Advanced domain selector| image:: https://raw.githubusercontent.com/OCA/web/19.0/web_x2many_searchable/static/description/03.png + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Add ``searchable="1"`` to the embedded ```` of the x2many field: + +.. code:: xml + + + + + + + + +How it works: + +- Type a query in the input, then press **Enter** to apply the filter. +- The applied filter is shown as a **facet (chip)**; the input is + cleared after applying. +- Click the **Advanced** button to edit an additional domain; it is also + shown as a facet. +- Use the facet remove icon or the **Clear** button to reset filters. + +Known issues / Roadmap +====================== + +- Optionally display a human-readable summary of the advanced domain + facet. +- Add an option to search beyond ``name_search`` (e.g. multi-field + global search), while keeping performance predictable. + +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 +------- + +* Felix Coca + +Contributors +------------ + +- Felix Coca daniel.uremix@gmail.com + +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_x2many_searchable/__init__.py b/web_x2many_searchable/__init__.py new file mode 100644 index 000000000000..792d6005489e --- /dev/null +++ b/web_x2many_searchable/__init__.py @@ -0,0 +1 @@ +# diff --git a/web_x2many_searchable/__manifest__.py b/web_x2many_searchable/__manifest__.py new file mode 100644 index 000000000000..64d2c30d92b2 --- /dev/null +++ b/web_x2many_searchable/__manifest__.py @@ -0,0 +1,22 @@ +{ + "name": "X2Many inline search (form sub-list)", + "version": "19.0.1.0.0", + "category": "Hidden/Technical", + "summary": "Optional quick search for x2many embedded list in form views", + "author": "Felix Coca, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/web", + "license": "LGPL-3", + "depends": ["web"], + "assets": { + "web.assets_backend": [ + "web_x2many_searchable/static/src/js/**/*.js", + "web_x2many_searchable/static/src/scss/**/*.scss", + "web_x2many_searchable/static/src/xml/**/*.xml", + ], + "web.qunit_suite_tests": [ + "web_x2many_searchable/static/tests/**/*.test.js", + ], + }, + "installable": True, + "application": False, +} diff --git a/web_x2many_searchable/pyproject.toml b/web_x2many_searchable/pyproject.toml new file mode 100644 index 000000000000..4231d0cccb3d --- /dev/null +++ b/web_x2many_searchable/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/web_x2many_searchable/readme/CONTRIBUTORS.md b/web_x2many_searchable/readme/CONTRIBUTORS.md new file mode 100644 index 000000000000..0bc5e4bb9489 --- /dev/null +++ b/web_x2many_searchable/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Felix Coca + diff --git a/web_x2many_searchable/readme/DESCRIPTION.md b/web_x2many_searchable/readme/DESCRIPTION.md new file mode 100644 index 000000000000..813a88e836e9 --- /dev/null +++ b/web_x2many_searchable/readme/DESCRIPTION.md @@ -0,0 +1,20 @@ +This module adds an optional **inline quick search** to x2many embedded lists inside form views. + +It is activated per subview by adding `searchable="1"` on the embedded `` node. + +Key behaviors: + +- Core-like UX with **facets (chips)**: typing does not create facets; filtering is applied on **Enter**. +- An **Advanced** domain editor is provided via Odoo's domain selector dialog. +- Works with both list implementations used by x2many in forms: + - Dynamic lists: apply domains through `list.load({domain})`. + - Static lists: emulate filtering by replacing x2many ids with the result of a server-side `search()`, constrained to the original relation ids. + +## Screenshots + +![Inline search input](../static/description/01.png) + +![Applied search facet (chip)](../static/description/02.png) + +![Advanced domain selector](../static/description/03.png) + diff --git a/web_x2many_searchable/readme/ROADMAP.md b/web_x2many_searchable/readme/ROADMAP.md new file mode 100644 index 000000000000..5d5b1c0f0dde --- /dev/null +++ b/web_x2many_searchable/readme/ROADMAP.md @@ -0,0 +1,3 @@ +- Optionally display a human-readable summary of the advanced domain facet. +- Add an option to search beyond `name_search` (e.g. multi-field global search), while keeping performance predictable. + diff --git a/web_x2many_searchable/readme/USAGE.md b/web_x2many_searchable/readme/USAGE.md new file mode 100644 index 000000000000..94aecd6fe9d7 --- /dev/null +++ b/web_x2many_searchable/readme/USAGE.md @@ -0,0 +1,18 @@ +Add `searchable="1"` to the embedded `` of the x2many field: + +```xml + + + + + + +``` + +How it works: + +- Type a query in the input, then press **Enter** to apply the filter. +- The applied filter is shown as a **facet (chip)**; the input is cleared after applying. +- Click the **Advanced** button to edit an additional domain; it is also shown as a facet. +- Use the facet remove icon or the **Clear** button to reset filters. + diff --git a/web_x2many_searchable/static/description/01.png b/web_x2many_searchable/static/description/01.png new file mode 100644 index 000000000000..2ce8b61d0348 Binary files /dev/null and b/web_x2many_searchable/static/description/01.png differ diff --git a/web_x2many_searchable/static/description/02.png b/web_x2many_searchable/static/description/02.png new file mode 100644 index 000000000000..836f8f3a3503 Binary files /dev/null and b/web_x2many_searchable/static/description/02.png differ diff --git a/web_x2many_searchable/static/description/03.png b/web_x2many_searchable/static/description/03.png new file mode 100644 index 000000000000..9b993591b088 Binary files /dev/null and b/web_x2many_searchable/static/description/03.png differ diff --git a/web_x2many_searchable/static/description/index.html b/web_x2many_searchable/static/description/index.html new file mode 100644 index 000000000000..b5cb732eb35d --- /dev/null +++ b/web_x2many_searchable/static/description/index.html @@ -0,0 +1,479 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

X2Many inline search (form sub-list)

+ +

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

+

This module adds an optional inline quick search to x2many embedded +lists inside form views.

+

It is activated per subview by adding searchable="1" on the embedded +<list> node.

+

Key behaviors:

+
    +
  • Core-like UX with facets (chips): typing does not create facets; +filtering is applied on Enter.
  • +
  • An Advanced domain editor is provided via Odoo’s domain selector +dialog.
  • +
  • Works with both list implementations used by x2many in forms:
      +
    • Dynamic lists: apply domains through list.load({domain}).
    • +
    • Static lists: emulate filtering by replacing x2many ids with the +result of a server-side search(), constrained to the original +relation ids.
    • +
    +
  • +
+
+

Screenshots

+

Inline search input

+

Applied search facet (chip)

+

Advanced domain selector

+

Table of contents

+ +
+

Usage

+

Add searchable="1" to the embedded <list> of the x2many field:

+
+<field name="line_ids">
+    <list editable="bottom" searchable="1">
+        <field name="product_id"/>
+        <field name="name"/>
+    </list>
+</field>
+
+

How it works:

+
    +
  • Type a query in the input, then press Enter to apply the filter.
  • +
  • The applied filter is shown as a facet (chip); the input is +cleared after applying.
  • +
  • Click the Advanced button to edit an additional domain; it is also +shown as a facet.
  • +
  • Use the facet remove icon or the Clear button to reset filters.
  • +
+
+
+

Known issues / Roadmap

+
    +
  • Optionally display a human-readable summary of the advanced domain +facet.
  • +
  • Add an option to search beyond name_search (e.g. multi-field +global search), while keeping performance predictable.
  • +
+
+
+

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.

+
+ +
+
+

Authors

+
    +
  • Felix Coca
  • +
+
+
+

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_x2many_searchable/static/src/js/x2many_searchable.esm.js b/web_x2many_searchable/static/src/js/x2many_searchable.esm.js new file mode 100644 index 000000000000..16ed10be8541 --- /dev/null +++ b/web_x2many_searchable/static/src/js/x2many_searchable.esm.js @@ -0,0 +1,296 @@ +/* @odoo-module */ +/** + * X2Many inline quick search (embedded in form views). + * + * Activated only when the x2many subview has `searchable="1"` on its `` node. + * + * UI: + * - Search bar is displayed in the x2many subview. + * - Filter based on the input value and the advanced domain. + * - Once applied, the input is cleared and facets become the source of truth. + */ + +/* eslint-disable sort-imports -- keep @web paths grouped; rule conflicts with @odoo/owl placement */ +import {Domain} from "@web/core/domain"; +import {DomainSelectorDialog} from "@web/core/domain_selector_dialog/domain_selector_dialog"; +import {_t} from "@web/core/l10n/translation"; +import {user} from "@web/core/user"; +import {useService} from "@web/core/utils/hooks"; +import {patch} from "@web/core/utils/patch"; +import {getFieldDomain} from "@web/model/relational_model/utils"; +import {X2ManyField} from "@web/views/fields/x2many/x2many_field"; + +import {useState} from "@odoo/owl"; +/* eslint-enable sort-imports */ + +function parseArchBool(value) { + return value === "1" || value === "true" || value === "True"; +} + +patch(X2ManyField.prototype, { + setup() { + super.setup(); + this.dialogService = useService("dialog"); + this.notificationService = useService("notification"); + this.orm = useService("orm"); + + /** + * - query/advancedDomain: current user input (not necessarily applied). + * - appliedQuery/appliedAdvancedDomain: currently applied filters, rendered as facets. + */ + this.x2mSearchState = useState({ + query: "", + advancedDomain: "[]", + appliedQuery: "", + appliedAdvancedDomain: "[]", + originalStaticIds: null, + disabled: false, + disabledReason: _t("Save record to search"), + placeholder: _t("Search in lines..."), + clearLabel: _t("Clear"), + advancedLabel: _t("Advanced"), + advancedActiveLabel: _t("Advanced domain active"), + isLoading: false, + }); + + this._x2mSearchRecomputeDisabled(); + }, + + get isX2ManySearchable() { + const xmlDoc = this.archInfo?.xmlDoc; + if (!xmlDoc) { + return false; + } + return parseArchBool(xmlDoc.getAttribute("searchable")); + }, + + get x2mFacets() { + const facets = []; + const query = (this.x2mSearchState.appliedQuery || "").trim(); + if (query) { + facets.push({ + key: "query", + type: "filter", + title: _t("Search"), + values: [query], + separator: _t("or"), + domain: null, + tooltip: null, + }); + } + if ( + this.x2mSearchState.appliedAdvancedDomain && + this.x2mSearchState.appliedAdvancedDomain !== "[]" + ) { + facets.push({ + key: "advanced", + type: "filter", + title: _t("Advanced"), + values: [_t("Domain")], + separator: _t("or"), + domain: this.x2mSearchState.appliedAdvancedDomain, + tooltip: this.x2mSearchState.appliedAdvancedDomain, + }); + } + return facets; + }, + + _x2mSearchRecomputeDisabled() { + const list = this.list; + const canSearch = Boolean( + list && typeof list.load === "function" && list.resModel + ); + const parentIsNew = Boolean(this.props?.record?.isNew); + this.x2mSearchState.disabled = !canSearch || parentIsNew; + if (parentIsNew) { + this.x2mSearchState.disabledReason = _t("Save the record to search"); + } else if (!canSearch) { + this.x2mSearchState.disabledReason = _t( + "Search is not available for this list" + ); + } + }, + + // eslint-disable-next-line complexity -- name_search + static/dynamic x2many branches + async _x2mApplySearch({force = false} = {}) { + this._x2mSearchRecomputeDisabled(); + if (this.x2mSearchState.disabled) { + return; + } + const list = this.list; + if (!list) { + return; + } + + const isStaticList = list.constructor?.type === "StaticList"; + const evalContext = list.evalContext || { + ...user.context, + ...this.props.context, + }; + const rpcContext = list.context || this.props.context || {}; + + const baseDomain = + getFieldDomain(this.props.record, this.props.name, this.props.domain) || []; + + let advancedDomain = []; + const advanced = this.x2mSearchState.advancedDomain || "[]"; + if (advanced && advanced !== "[]") { + try { + advancedDomain = new Domain(advanced).toList(evalContext); + } catch { + advancedDomain = []; + } + } + + let quickDomain = []; + const query = (this.x2mSearchState.query || "").trim(); + if (query) { + this.x2mSearchState.isLoading = true; + try { + const lookupDomain = Domain.and([ + new Domain(baseDomain), + new Domain(advancedDomain), + ...(isStaticList + ? [ + new Domain([ + [ + "id", + "in", + this.x2mSearchState.originalStaticIds || + list.currentIds || + [], + ], + ]), + ] + : []), + ]).toList(evalContext); + const nameGets = await this.orm.call(list.resModel, "name_search", [], { + name: query, + domain: lookupDomain, + operator: "ilike", + limit: 200, + context: rpcContext, + }); + const ids = nameGets.map((ng) => ng[0]); + quickDomain = ids.length ? [["id", "in", ids]] : [["id", "=", 0]]; + } finally { + this.x2mSearchState.isLoading = false; + } + } + + const finalDomain = Domain.and([ + new Domain(baseDomain), + new Domain(advancedDomain), + new Domain(quickDomain), + ]).toList(evalContext); + + const finalKey = JSON.stringify(finalDomain); + if (!force && this._x2mLastDomainKey === finalKey) { + return; + } + this._x2mLastDomainKey = finalKey; + + try { + if (isStaticList) { + if (this.x2mSearchState.originalStaticIds === null) { + this.x2mSearchState.originalStaticIds = [ + ...(list.currentIds || []), + ]; + } + const constrainedDomain = Domain.and([ + new Domain(finalDomain), + new Domain([["id", "in", this.x2mSearchState.originalStaticIds]]), + ]).toList(evalContext); + const ids = await this.orm.search(list.resModel, constrainedDomain, { + context: rpcContext, + limit: 1000, + }); + await list._replaceWith(ids, {reload: true}); + await list.load({offset: 0}); + } else { + await list.load({domain: finalDomain, offset: 0}); + } + this.x2mSearchState.appliedQuery = (this.x2mSearchState.query || "").trim(); + this.x2mSearchState.appliedAdvancedDomain = + this.x2mSearchState.advancedDomain || "[]"; + this.x2mSearchState.query = ""; + this.render(); + } catch { + this.x2mSearchState.disabled = true; + this.notificationService.add(_t("Search is not available for this list"), { + type: "warning", + }); + } + }, + + onX2mSearchInput(ev) { + this.x2mSearchState.query = ev.target.value || ""; + }, + + onX2mSearchKeydown(ev) { + if (ev.key === "Enter") { + ev.preventDefault(); + this._x2mApplySearch({force: true}); + } + }, + + onX2mClearSearch() { + this.x2mSearchState.query = ""; + this.x2mSearchState.advancedDomain = "[]"; + this.x2mSearchState.appliedQuery = ""; + this.x2mSearchState.appliedAdvancedDomain = "[]"; + this._x2mLastDomainKey = null; + const list = this.list; + if ( + list && + list.constructor?.type === "StaticList" && + this.x2mSearchState.originalStaticIds + ) { + const ids = this.x2mSearchState.originalStaticIds; + this.x2mSearchState.originalStaticIds = null; + return list + ._replaceWith(ids, {reload: true}) + .then(() => list.load({offset: 0})) + .then(() => this.render()); + } + return this._x2mApplySearch({force: true}); + }, + + onX2mFacetRemove(facet) { + if (facet.key === "query") { + this.x2mSearchState.query = ""; + this.x2mSearchState.appliedQuery = ""; + } else if (facet.key === "advanced") { + this.x2mSearchState.advancedDomain = "[]"; + this.x2mSearchState.appliedAdvancedDomain = "[]"; + } + this._x2mLastDomainKey = null; + return this._x2mApplySearch({force: true}); + }, + + onX2mFacetLabelClick(_target, facet) { + if (facet.key === "advanced") { + return this.onX2mAdvancedDomain(); + } + }, + + onX2mAdvancedDomain() { + const resModel = this.list?.resModel; + if (!resModel) { + return; + } + this.dialogService.add(DomainSelectorDialog, { + resModel, + domain: this.x2mSearchState.advancedDomain || "[]", + context: this.list?.evalContext || this.props.context || {}, + title: _t("Advanced search domain"), + confirmButtonText: _t("Apply"), + discardButtonText: _t("Cancel"), + disableConfirmButton: (domain) => domain === "[]", + onConfirm: (domain) => { + this.x2mSearchState.advancedDomain = domain || "[]"; + this._x2mApplySearch({force: true}); + }, + }); + }, +}); diff --git a/web_x2many_searchable/static/src/scss/x2many_searchable.scss b/web_x2many_searchable/static/src/scss/x2many_searchable.scss new file mode 100644 index 000000000000..b6774db8eebe --- /dev/null +++ b/web_x2many_searchable/static/src/scss/x2many_searchable.scss @@ -0,0 +1,18 @@ +// Keep the inline x2many search bar above the list header +// on very small screens where the layout can overlap. +.o_field_x2many_list { + .o_x2m_search_panel { + position: relative; + z-index: 2; + background-color: var(--o-view-background-color); + } +} + +@include media-breakpoint-down(md) { + .o_field_x2many_list { + .o_x2m_search_panel { + position: sticky; + top: 0; + } + } +} diff --git a/web_x2many_searchable/static/src/xml/x2many_searchable.xml b/web_x2many_searchable/static/src/xml/x2many_searchable.xml new file mode 100644 index 000000000000..c03a41a7ebe5 --- /dev/null +++ b/web_x2many_searchable/static/src/xml/x2many_searchable.xml @@ -0,0 +1,132 @@ + + + + + +