diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/control_panel/search_bar.js | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (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.js | 493 |
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; +}); |
