summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/control_panel/search_bar.js
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/control_panel/search_bar.js
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/control_panel/search_bar.js')
-rw-r--r--addons/web/static/src/js/control_panel/search_bar.js493
1 files changed, 493 insertions, 0 deletions
diff --git a/addons/web/static/src/js/control_panel/search_bar.js b/addons/web/static/src/js/control_panel/search_bar.js
new file mode 100644
index 00000000..dd0d4460
--- /dev/null
+++ b/addons/web/static/src/js/control_panel/search_bar.js
@@ -0,0 +1,493 @@
+odoo.define('web.SearchBar', function (require) {
+ "use strict";
+
+ const Domain = require('web.Domain');
+ const field_utils = require('web.field_utils');
+ const { useAutofocus } = require('web.custom_hooks');
+ const { useModel } = require('web/static/src/js/model.js');
+
+ const CHAR_FIELDS = ['char', 'html', 'many2many', 'many2one', 'one2many', 'text'];
+ const { Component, hooks } = owl;
+ const { useExternalListener, useRef, useState } = hooks;
+
+ let sourceId = 0;
+
+ /**
+ * Search bar
+ *
+ * This component has two main roles:
+ * 1) Display the current search facets
+ * 2) Create new search filters using an input and an autocompletion values
+ * generator.
+ *
+ * For the first bit, the core logic can be found in the XML template of this
+ * component, searchfacet components or in the ControlPanelModel itself.
+ *
+ * The autocompletion mechanic works with transient subobjects called 'sources'.
+ * Sources contain the information that will be used to generate new search facets.
+ * A source is generated either:
+ * a. From an undetermined user input: the user will give a string and select
+ * a field from the autocompletion dropdown > this will search the selected
+ * field records with the given pattern (with an 'ilike' operator);
+ * b. From a given selection: when given an input by the user, the searchbar
+ * will pre-fetch 'many2one' field records matching the input value and filter
+ * 'select' fields with the same value. If the user clicks on one of these
+ * fetched/filtered values, it will generate a matching search facet targeting
+ * records having this exact value.
+ * @extends Component
+ */
+ class SearchBar extends Component {
+ constructor() {
+ super(...arguments);
+
+ this.focusOnUpdate = useAutofocus();
+ this.inputRef = useRef('search-input');
+ this.model = useModel('searchModel');
+ this.state = useState({
+ sources: [],
+ focusedItem: 0,
+ inputValue: "",
+ });
+
+ this.autoCompleteSources = this.model.get('filters', f => f.type === 'field').map(
+ filter => this._createSource(filter)
+ );
+ this.noResultItem = [null, this.env._t("(no result)")];
+
+ useExternalListener(window, 'click', this._onWindowClick);
+ useExternalListener(window, 'keydown', this._onWindowKeydown);
+ }
+
+ mounted() {
+ // 'search' will always patch the search bar, 'focus' will never.
+ this.env.searchModel.on('search', this, this.focusOnUpdate);
+ this.env.searchModel.on('focus-control-panel', this, () => {
+ this.inputRef.el.focus();
+ });
+ }
+
+ willUnmount() {
+ this.env.searchModel.off('search', this);
+ this.env.searchModel.off('focus-control-panel', this);
+ }
+
+ //---------------------------------------------------------------------
+ // Private
+ //---------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _closeAutoComplete() {
+ this.state.sources = [];
+ this.state.focusedItem = 0;
+ this.state.inputValue = "";
+ this.inputRef.el.value = "";
+ this.focusOnUpdate();
+ }
+
+ /**
+ * @private
+ * @param {Object} filter
+ * @returns {Object}
+ */
+ _createSource(filter) {
+ const field = this.props.fields[filter.fieldName];
+ const type = field.type === "reference" ? "char" : field.type;
+ const source = {
+ active: true,
+ description: filter.description,
+ filterId: filter.id,
+ filterOperator: filter.operator,
+ id: sourceId ++,
+ operator: CHAR_FIELDS.includes(type) ? 'ilike' : '=',
+ parent: false,
+ type,
+ };
+ switch (type) {
+ case 'selection': {
+ source.active = false;
+ source.selection = field.selection || [];
+ break;
+ }
+ case 'boolean': {
+ source.active = false;
+ source.selection = [
+ [true, this.env._t("Yes")],
+ [false, this.env._t("No")],
+ ];
+ break;
+ }
+ case 'many2one': {
+ source.expand = true;
+ source.expanded = false;
+ source.context = field.context;
+ source.relation = field.relation;
+ if (filter.domain) {
+ source.domain = filter.domain;
+ }
+ }
+ }
+ return source;
+ }
+
+ /**
+ * @private
+ * @param {Object} source
+ * @param {[any, string]} values
+ * @param {boolean} [active=true]
+ */
+ _createSubSource(source, [value, label], active = true) {
+ const subSource = {
+ active,
+ filterId: source.filterId,
+ filterOperator: source.filterOperator,
+ id: sourceId ++,
+ label,
+ operator: '=',
+ parent: source,
+ value,
+ };
+ return subSource;
+ }
+
+ /**
+ * @private
+ * @param {Object} source
+ * @param {boolean} shouldExpand
+ */
+ async _expandSource(source, shouldExpand) {
+ source.expanded = shouldExpand;
+ if (shouldExpand) {
+ let args = source.domain;
+ if (typeof args === 'string') {
+ try {
+ args = Domain.prototype.stringToArray(args);
+ } catch (err) {
+ args = [];
+ }
+ }
+ const results = await this.rpc({
+ kwargs: {
+ args,
+ context: source.context,
+ limit: 8,
+ name: this.state.inputValue.trim(),
+ },
+ method: 'name_search',
+ model: source.relation,
+ });
+ const options = results.map(result => this._createSubSource(source, result));
+ const parentIndex = this.state.sources.indexOf(source);
+ if (!options.length) {
+ options.push(this._createSubSource(source, this.noResultItem, false));
+ }
+ this.state.sources.splice(parentIndex + 1, 0, ...options);
+ } else {
+ this.state.sources = this.state.sources.filter(src => src.parent !== source);
+ }
+ }
+
+ /**
+ * @private
+ * @param {string} query
+ */
+ _filterSources(query) {
+ return this.autoCompleteSources.reduce(
+ (sources, source) => {
+ // Field selection or boolean.
+ if (source.selection) {
+ const options = [];
+ source.selection.forEach(result => {
+ if (fuzzy.test(query, result[1].toLowerCase())) {
+ options.push(this._createSubSource(source, result));
+ }
+ });
+ if (options.length) {
+ sources.push(source, ...options);
+ }
+ // Any other type.
+ } else if (this._validateSource(query, source)) {
+ sources.push(source);
+ }
+ // Fold any expanded item.
+ if (source.expanded) {
+ source.expanded = false;
+ }
+ return sources;
+ },
+ []
+ );
+ }
+
+ /**
+ * Focus the search facet at the designated index if any.
+ * @private
+ */
+ _focusFacet(index) {
+ const facets = this.el.getElementsByClassName('o_searchview_facet');
+ if (facets.length) {
+ facets[index].focus();
+ }
+ }
+
+ /**
+ * Try to parse the given rawValue according to the type of the given
+ * source field type. The returned formatted value is the one that will
+ * supposedly be sent to the server.
+ * @private
+ * @param {string} rawValue
+ * @param {Object} source
+ * @returns {string}
+ */
+ _parseWithSource(rawValue, { type }) {
+ const parser = field_utils.parse[type];
+ let parsedValue;
+ switch (type) {
+ case 'date':
+ case 'datetime': {
+ const parsedDate = parser(rawValue, { type }, { timezone: true });
+ const dateFormat = type === 'datetime' ? 'YYYY-MM-DD HH:mm:ss' : 'YYYY-MM-DD';
+ const momentValue = moment(parsedDate, dateFormat);
+ if (!momentValue.isValid()) {
+ throw new Error('Invalid date');
+ }
+ parsedValue = parsedDate.toJSON();
+ break;
+ }
+ case 'many2one': {
+ parsedValue = rawValue;
+ break;
+ }
+ default: {
+ parsedValue = parser(rawValue);
+ }
+ }
+ return parsedValue;
+ }
+
+ /**
+ * @private
+ * @param {Object} source
+ */
+ _selectSource(source) {
+ // Inactive sources are:
+ // - Selection sources
+ // - "no result" items
+ if (source.active) {
+ const labelValue = source.label || this.state.inputValue;
+ this.model.dispatch('addAutoCompletionValues', {
+ filterId: source.filterId,
+ value: "value" in source ? source.value : this._parseWithSource(labelValue, source),
+ label: labelValue,
+ operator: source.filterOperator || source.operator,
+ });
+ }
+ this._closeAutoComplete();
+ }
+
+ /**
+ * @private
+ * @param {string} query
+ * @param {Object} source
+ * @returns {boolean}
+ */
+ _validateSource(query, source) {
+ try {
+ this._parseWithSource(query, source);
+ } catch (err) {
+ return false;
+ }
+ return true;
+ }
+
+ //---------------------------------------------------------------------
+ // Handlers
+ //---------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} facet
+ * @param {number} facetIndex
+ * @param {KeyboardEvent} ev
+ */
+ _onFacetKeydown(facet, facetIndex, ev) {
+ switch (ev.key) {
+ case 'ArrowLeft':
+ if (facetIndex === 0) {
+ this.inputRef.el.focus();
+ } else {
+ this._focusFacet(facetIndex - 1);
+ }
+ break;
+ case 'ArrowRight':
+ const facets = this.el.getElementsByClassName('o_searchview_facet');
+ if (facetIndex === facets.length - 1) {
+ this.inputRef.el.focus();
+ } else {
+ this._focusFacet(facetIndex + 1);
+ }
+ break;
+ case 'Backspace':
+ this._onFacetRemove(facet);
+ break;
+ }
+ }
+
+ /**
+ * @private
+ * @param {Object} facet
+ */
+ _onFacetRemove(facet) {
+ this.model.dispatch('deactivateGroup', facet.groupId);
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onSearchKeydown(ev) {
+ if (ev.isComposing) {
+ // This case happens with an IME for example: we let it handle all key events.
+ return;
+ }
+ const currentItem = this.state.sources[this.state.focusedItem] || {};
+ switch (ev.key) {
+ case 'ArrowDown':
+ ev.preventDefault();
+ if (Object.keys(this.state.sources).length) {
+ let nextIndex = this.state.focusedItem + 1;
+ if (nextIndex >= this.state.sources.length) {
+ nextIndex = 0;
+ }
+ this.state.focusedItem = nextIndex;
+ } else {
+ this.env.bus.trigger('focus-view');
+ }
+ break;
+ case 'ArrowLeft':
+ if (currentItem.expanded) {
+ // Priority 1: fold expanded item.
+ ev.preventDefault();
+ this._expandSource(currentItem, false);
+ } else if (currentItem.parent) {
+ // Priority 2: focus parent item.
+ ev.preventDefault();
+ this.state.focusedItem = this.state.sources.indexOf(currentItem.parent);
+ // Priority 3: Do nothing (navigation inside text).
+ } else if (ev.target.selectionStart === 0) {
+ // Priority 4: navigate to rightmost facet.
+ this._focusFacet(this.model.get("facets").length - 1);
+ }
+ break;
+ case 'ArrowRight':
+ if (ev.target.selectionStart === this.state.inputValue.length) {
+ // Priority 1: Do nothing (navigation inside text).
+ if (currentItem.expand) {
+ // Priority 2: go to first child or expand item.
+ ev.preventDefault();
+ if (currentItem.expanded) {
+ this.state.focusedItem ++;
+ } else {
+ this._expandSource(currentItem, true);
+ }
+ } else if (ev.target.selectionStart === this.state.inputValue.length) {
+ // Priority 3: navigate to leftmost facet.
+ this._focusFacet(0);
+ }
+ }
+ break;
+ case 'ArrowUp':
+ ev.preventDefault();
+ let previousIndex = this.state.focusedItem - 1;
+ if (previousIndex < 0) {
+ previousIndex = this.state.sources.length - 1;
+ }
+ this.state.focusedItem = previousIndex;
+ break;
+ case 'Backspace':
+ if (!this.state.inputValue.length) {
+ const facets = this.model.get("facets");
+ if (facets.length) {
+ this._onFacetRemove(facets[facets.length - 1]);
+ }
+ }
+ break;
+ case 'Enter':
+ if (!this.state.inputValue.length) {
+ this.model.dispatch('search');
+ break;
+ }
+ /* falls through */
+ case 'Tab':
+ if (this.state.inputValue.length) {
+ ev.preventDefault(); // keep the focus inside the search bar
+ this._selectSource(currentItem);
+ }
+ break;
+ case 'Escape':
+ if (this.state.sources.length) {
+ this._closeAutoComplete();
+ }
+ break;
+ }
+ }
+
+ /**
+ * @private
+ * @param {InputEvent} ev
+ */
+ _onSearchInput(ev) {
+ this.state.inputValue = ev.target.value;
+ const wasVisible = this.state.sources.length;
+ const query = this.state.inputValue.trim().toLowerCase();
+ if (query.length) {
+ this.state.sources = this._filterSources(query);
+ } else if (wasVisible) {
+ this._closeAutoComplete();
+ }
+ }
+
+ /**
+ * Only handled if the user has moved its cursor at least once after the
+ * results are loaded and displayed.
+ * @private
+ * @param {number} resultIndex
+ */
+ _onSourceMousemove(resultIndex) {
+ this.state.focusedItem = resultIndex;
+ }
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onWindowClick(ev) {
+ if (this.state.sources.length && !this.el.contains(ev.target)) {
+ this._closeAutoComplete();
+ }
+ }
+
+ /**
+ * @private
+ * @param {KeyboardEvent} ev
+ */
+ _onWindowKeydown(ev) {
+ if (ev.key === 'Escape' && this.state.sources.length) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ this._closeAutoComplete();
+ }
+ }
+ }
+
+ SearchBar.defaultProps = {
+ fields: {},
+ };
+ SearchBar.props = {
+ fields: Object,
+ };
+ SearchBar.template = 'web.SearchBar';
+
+ return SearchBar;
+});