summaryrefslogtreecommitdiff
path: root/addons/point_of_sale/static/src/js/Misc
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/point_of_sale/static/src/js/Misc
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/point_of_sale/static/src/js/Misc')
-rw-r--r--addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js62
-rw-r--r--addons/point_of_sale/static/src/js/Misc/Draggable.js142
-rw-r--r--addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js23
-rw-r--r--addons/point_of_sale/static/src/js/Misc/MobileOrderWidget.js39
-rw-r--r--addons/point_of_sale/static/src/js/Misc/NotificationSound.js19
-rw-r--r--addons/point_of_sale/static/src/js/Misc/NumberBuffer.js297
-rw-r--r--addons/point_of_sale/static/src/js/Misc/SearchBar.js115
7 files changed, 697 insertions, 0 deletions
diff --git a/addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js b/addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js
new file mode 100644
index 00000000..2ebdce20
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Misc/AbstractReceiptScreen.js
@@ -0,0 +1,62 @@
+odoo.define('point_of_sale.AbstractReceiptScreen', function (require) {
+ 'use strict';
+
+ const { useRef } = owl.hooks;
+ const { nextFrame } = require('point_of_sale.utils');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ /**
+ * This relies on the assumption that there is a reference to
+ * `order-receipt` so it is important to declare a `t-ref` to
+ * `order-receipt` in the template of the Component that extends
+ * this abstract component.
+ */
+ class AbstractReceiptScreen extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.orderReceipt = useRef('order-receipt');
+ }
+ async _printReceipt() {
+ if (this.env.pos.proxy.printer) {
+ const printResult = await this.env.pos.proxy.printer.print_receipt(this.orderReceipt.el.outerHTML);
+ if (printResult.successful) {
+ return true;
+ } else {
+ const { confirmed } = await this.showPopup('ConfirmPopup', {
+ title: printResult.message.title,
+ body: 'Do you want to print using the web printer?',
+ });
+ if (confirmed) {
+ // We want to call the _printWeb when the popup is fully gone
+ // from the screen which happens after the next animation frame.
+ await nextFrame();
+ return await this._printWeb();
+ }
+ return false;
+ }
+ } else {
+ return await this._printWeb();
+ }
+ }
+ async _printWeb() {
+ try {
+ window.print();
+ return true;
+ } catch (err) {
+ await this.showPopup('ErrorPopup', {
+ title: this.env._t('Printing is not supported on some browsers'),
+ body: this.env._t(
+ 'Printing is not supported on some browsers due to no default printing protocol ' +
+ 'is available. It is possible to print your tickets by making use of an IoT Box.'
+ ),
+ });
+ return false;
+ }
+ }
+ }
+
+ Registries.Component.add(AbstractReceiptScreen);
+
+ return AbstractReceiptScreen;
+});
diff --git a/addons/point_of_sale/static/src/js/Misc/Draggable.js b/addons/point_of_sale/static/src/js/Misc/Draggable.js
new file mode 100644
index 00000000..cbb1eba8
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Misc/Draggable.js
@@ -0,0 +1,142 @@
+odoo.define('point_of_sale.Draggable', function(require) {
+ 'use strict';
+
+ const { useExternalListener } = owl.hooks;
+ const { useListener } = require('web.custom_hooks');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ /**
+ * Wrap an element or a component with { position: absolute } to make it
+ * draggable around the limitArea or the nearest positioned ancestor.
+ *
+ * e.g.
+ * ```
+ * <div class="limit-area">
+ * <Draggable limitArea="'.limit-area'">
+ * <div class="popup">
+ * <header class="drag-handle"></header>
+ * </div>
+ * <div class="popup body"></div>
+ * </Draggable>
+ * </div>
+ * ```
+ *
+ * In the above snippet, if the popup div is { position: absolute },
+ * then it becomes draggable around the .limit-area element if it is dragged
+ * thru its Header -- because of the .drag-handle element.
+ *
+ * @trigger 'drag-end' when dragging ended with payload `{ loc: { top, left } }`
+ */
+ class Draggable extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.isDragging = false;
+ this.dx = 0;
+ this.dy = 0;
+ // drag with mouse
+ useExternalListener(document, 'mousemove', this.move);
+ useExternalListener(document, 'mouseup', this.endDrag);
+ // drag with touch
+ useExternalListener(document, 'touchmove', this.move);
+ useExternalListener(document, 'touchend', this.endDrag);
+
+ useListener('mousedown', '.drag-handle', this.startDrag);
+ useListener('touchstart', '.drag-handle', this.startDrag);
+ }
+ mounted() {
+ this.limitArea = this.props.limitArea
+ ? document.querySelector(this.props.limitArea)
+ : this.el.offsetParent;
+ this.limitAreaBoundingRect = this.limitArea.getBoundingClientRect();
+ if (this.limitArea === this.el.offsetParent) {
+ this.limitLeft = 0;
+ this.limitTop = 0;
+ this.limitRight = this.limitAreaBoundingRect.width;
+ this.limitBottom = this.limitAreaBoundingRect.height;
+ } else {
+ this.limitLeft = -this.el.offsetParent.offsetLeft;
+ this.limitTop = -this.el.offsetParent.offsetTop;
+ this.limitRight =
+ this.limitAreaBoundingRect.width - this.el.offsetParent.offsetLeft;
+ this.limitBottom =
+ this.limitAreaBoundingRect.height - this.el.offsetParent.offsetTop;
+ }
+ this.limitAreaWidth = this.limitAreaBoundingRect.width;
+ this.limitAreaHeight = this.limitAreaBoundingRect.height;
+
+ // absolutely position the element then remove the transform.
+ const elBoundingRect = this.el.getBoundingClientRect();
+ this.el.style.top = `${elBoundingRect.top}px`;
+ this.el.style.left = `${elBoundingRect.left}px`;
+ this.el.style.transform = 'none';
+ }
+ startDrag(event) {
+ let realEvent;
+ if (event instanceof CustomEvent) {
+ realEvent = event.detail;
+ } else {
+ realEvent = event;
+ }
+ const { x, y } = this._getEventLoc(realEvent);
+ this.isDragging = true;
+ this.dx = this.el.offsetLeft - x;
+ this.dy = this.el.offsetTop - y;
+ event.stopPropagation();
+ }
+ move(event) {
+ if (this.isDragging) {
+ const { x: pointerX, y: pointerY } = this._getEventLoc(event);
+ const posLeft = this._getPosLeft(pointerX, this.dx);
+ const posTop = this._getPosTop(pointerY, this.dy);
+ this.el.style.left = `${posLeft}px`;
+ this.el.style.top = `${posTop}px`;
+ }
+ }
+ endDrag() {
+ if (this.isDragging) {
+ this.isDragging = false;
+ this.trigger('drag-end', {
+ loc: { top: this.el.offsetTop, left: this.el.offsetLeft },
+ });
+ }
+ }
+ _getEventLoc(event) {
+ let coordX, coordY;
+ if (event.touches && event.touches[0]) {
+ coordX = event.touches[0].clientX;
+ coordY = event.touches[0].clientY;
+ } else {
+ coordX = event.clientX;
+ coordY = event.clientY;
+ }
+ return {
+ x: coordX,
+ y: coordY,
+ };
+ }
+ _getPosLeft(pointerX, dx) {
+ const posLeft = pointerX + dx;
+ if (posLeft < this.limitLeft) {
+ return this.limitLeft;
+ } else if (posLeft > this.limitRight - this.el.offsetWidth) {
+ return this.limitRight - this.el.offsetWidth;
+ }
+ return posLeft;
+ }
+ _getPosTop(pointerY, dy) {
+ const posTop = pointerY + dy;
+ if (posTop < this.limitTop) {
+ return this.limitTop;
+ } else if (posTop > this.limitBottom - this.el.offsetHeight) {
+ return this.limitBottom - this.el.offsetHeight;
+ }
+ return posTop;
+ }
+ }
+ Draggable.template = 'Draggable';
+
+ Registries.Component.add(Draggable);
+
+ return Draggable;
+});
diff --git a/addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js b/addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js
new file mode 100644
index 00000000..e2f2148b
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Misc/IndependentToOrderScreen.js
@@ -0,0 +1,23 @@
+odoo.define('point_of_sale.IndependentToOrderScreen', function (require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+
+ class IndependentToOrderScreen extends PosComponent {
+ /**
+ * Alias the forceTriggerSelectedOrder method as it also
+ * means 'closing' this screen.
+ */
+ close() {
+ this.forceTriggerSelectedOrder();
+ }
+ forceTriggerSelectedOrder() {
+ // Calling this method forcefully trigger change
+ // on the selectedOrder attribute, which then shows the screen of the
+ // current order, essentially closing this screen.
+ this.env.pos.trigger('change:selectedOrder', this.env.pos, this.env.pos.get_order());
+ }
+ }
+
+ return IndependentToOrderScreen;
+});
diff --git a/addons/point_of_sale/static/src/js/Misc/MobileOrderWidget.js b/addons/point_of_sale/static/src/js/Misc/MobileOrderWidget.js
new file mode 100644
index 00000000..024a77b3
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Misc/MobileOrderWidget.js
@@ -0,0 +1,39 @@
+odoo.define('point_of_sale.MobileOrderWidget', function(require) {
+ 'use strict';
+
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class MobileOrderWidget extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.pane = this.props.pane;
+ this.update();
+ }
+ get order() {
+ return this.env.pos.get_order();
+ }
+ mounted() {
+ this.order.on('change', () => {
+ this.update();
+ this.render();
+ });
+ this.order.orderlines.on('change', () => {
+ this.update();
+ this.render();
+ });
+ }
+ update() {
+ const total = this.order ? this.order.get_total_with_tax() : 0;
+ const tax = this.order ? total - this.order.get_total_without_tax() : 0;
+ this.total = this.env.pos.format_currency(total);
+ this.items_number = this.order ? this.order.orderlines.reduce((items_number,line) => items_number + line.quantity, 0) : 0;
+ }
+ }
+
+ MobileOrderWidget.template = 'MobileOrderWidget';
+
+ Registries.Component.add(MobileOrderWidget);
+
+ return MobileOrderWidget;
+});
diff --git a/addons/point_of_sale/static/src/js/Misc/NotificationSound.js b/addons/point_of_sale/static/src/js/Misc/NotificationSound.js
new file mode 100644
index 00000000..540e84f1
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Misc/NotificationSound.js
@@ -0,0 +1,19 @@
+odoo.define('point_of_sale.NotificationSound', function (require) {
+ 'use strict';
+
+ const { useListener } = require('web.custom_hooks');
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ class NotificationSound extends PosComponent {
+ constructor() {
+ super(...arguments);
+ useListener('ended', () => (this.props.sound.src = null));
+ }
+ }
+ NotificationSound.template = 'NotificationSound';
+
+ Registries.Component.add(NotificationSound);
+
+ return NotificationSound;
+});
diff --git a/addons/point_of_sale/static/src/js/Misc/NumberBuffer.js b/addons/point_of_sale/static/src/js/Misc/NumberBuffer.js
new file mode 100644
index 00000000..8e25f601
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Misc/NumberBuffer.js
@@ -0,0 +1,297 @@
+odoo.define('point_of_sale.NumberBuffer', function(require) {
+ 'use strict';
+
+ const { Component } = owl;
+ const { EventBus } = owl.core;
+ const { onMounted, onWillUnmount, useExternalListener } = owl.hooks;
+ const { useListener } = require('web.custom_hooks');
+ const { parse } = require('web.field_utils');
+ const { BarcodeEvents } = require('barcodes.BarcodeEvents');
+ const { _t } = require('web.core');
+ const { Gui } = require('point_of_sale.Gui');
+
+ const INPUT_KEYS = new Set(
+ ['Delete', 'Backspace', '+1', '+2', '+5', '+10', '+20', '+50'].concat('0123456789+-.,'.split(''))
+ );
+ const CONTROL_KEYS = new Set(['Enter', 'Esc']);
+ const ALLOWED_KEYS = new Set([...INPUT_KEYS, ...CONTROL_KEYS]);
+ const getDefaultConfig = () => ({
+ decimalPoint: false,
+ triggerAtEnter: false,
+ triggerAtEsc: false,
+ triggerAtInput: false,
+ nonKeyboardInputEvent: false,
+ useWithBarcode: false,
+ });
+
+ /**
+ * This is a singleton.
+ *
+ * Only one component can `use` the buffer at a time.
+ * This is done by keeping track of each component (and its
+ * corresponding state and config) using a stack (bufferHolderStack).
+ * The component on top of the stack is the one that currently
+ * `holds` the buffer.
+ *
+ * When the current component is unmounted, the top of the stack
+ * is popped and NumberBuffer is set up again for the new component
+ * on top of the stack.
+ *
+ * Usage
+ * =====
+ * - Activate in the construction of root component. `NumberBuffer.activate()`
+ * - Use the buffer in a child component by calling `NumberBuffer.use(<config>)`
+ * in the constructor of the child component.
+ * - The component that `uses` the buffer has access to the following instance
+ * methods of the NumberBuffer:
+ * - get()
+ * - set(val)
+ * - reset()
+ * - getFloat()
+ * - capture()
+ *
+ * Note
+ * ====
+ * - No need to instantiate as it is a singleton created before exporting in this module.
+ *
+ * Possible Improvements
+ * =====================
+ * - Relieve the buffer from responsibility of handling `Enter` and other control keys.
+ * - Make the constants (ALLOWED_KEYS, etc.) more configurable.
+ * - Write more integration tests. NumberPopup can be used as test component.
+ */
+ class NumberBuffer extends EventBus {
+ constructor() {
+ super();
+ this.isReset = false;
+ this.bufferHolderStack = [];
+ }
+ /**
+ * @returns {String} value of the buffer, e.g. '-95.79'
+ */
+ get() {
+ return this.state ? this.state.buffer : null;
+ }
+ /**
+ * Takes a string that is convertible to float, and set it as
+ * value of the buffer. e.g. val = '2.99';
+ *
+ * @param {String} val
+ */
+ set(val) {
+ this.state.buffer = !isNaN(parseFloat(val)) ? val : '';
+ this.trigger('buffer-update', this.state.buffer);
+ }
+ /**
+ * Resets the buffer to empty string.
+ */
+ reset() {
+ this.isReset = true;
+ this.state.buffer = '';
+ this.trigger('buffer-update', this.state.buffer);
+ }
+ /**
+ * Calling this function, we immediately invoke the `handler` method
+ * that handles the contents of the input events buffer (`eventsBuffer`).
+ * This is helpful when we don't want to wait for the timeout that
+ * is supposed to invoke the handler.
+ */
+ capture() {
+ if (this.handler) {
+ clearTimeout(this._timeout);
+ this.handler();
+ delete this.handler;
+ }
+ }
+ /**
+ * @returns {number} float equivalent of the value of buffer
+ */
+ getFloat() {
+ return parse.float(this.get());
+ }
+ /**
+ * Add keyup listener to window via the useExternalListener hook.
+ * When the component calling this is unmounted, the listener is also
+ * removed from window.
+ */
+ activate() {
+ this.defaultDecimalPoint = _t.database.parameters.decimal_point;
+ useExternalListener(window, 'keyup', this._onKeyboardInput.bind(this));
+ }
+ /**
+ * @param {Object} config Use to setup the buffer
+ * @param {String|null} config.decimalPoint The decimal character.
+ * @param {String|null} config.triggerAtEnter Event triggered when 'Enter' key is pressed.
+ * @param {String|null} config.triggerAtEsc Event triggered when 'Esc' key is pressed.
+ * @param {String|null} config.triggerAtInput Event triggered for every accepted input.
+ * @param {String|null} config.nonKeyboardInputEvent Also listen to a non-keyboard input event
+ * that carries a payload of { key }. The key is checked if it is a valid input. If valid,
+ * the number buffer is modified just as it is modified when a keyboard key is pressed.
+ * @param {Boolean} config.useWithBarcode Whether this buffer is used with barcode.
+ * @emits config.triggerAtEnter when 'Enter' key is pressed.
+ * @emits config.triggerAtEsc when 'Esc' key is pressed.
+ * @emits config.triggerAtInput when an input is accepted.
+ */
+ use(config) {
+ this.eventsBuffer = [];
+ const currentComponent = Component.current;
+ config = Object.assign(getDefaultConfig(), config);
+ onMounted(() => {
+ this.bufferHolderStack.push({
+ component: currentComponent,
+ state: config.state ? config.state : { buffer: '' },
+ config,
+ });
+ this._setUp();
+ });
+ onWillUnmount(() => {
+ this.bufferHolderStack.pop();
+ this._setUp();
+ });
+ // Add listener that accepts non keyboard inputs
+ if (typeof config.nonKeyboardInputEvent === 'string') {
+ useListener(config.nonKeyboardInputEvent, this._onNonKeyboardInput.bind(this));
+ }
+ }
+ get _currentBufferHolder() {
+ return this.bufferHolderStack[this.bufferHolderStack.length - 1];
+ }
+ _setUp() {
+ if (!this._currentBufferHolder) return;
+ const { component, state, config } = this._currentBufferHolder;
+ this.component = component;
+ this.state = state;
+ this.config = config;
+ this.decimalPoint = config.decimalPoint || this.defaultDecimalPoint;
+ this.maxTimeBetweenKeys = this.config.useWithBarcode
+ ? BarcodeEvents.max_time_between_keys_in_ms
+ : 0;
+ }
+ _onKeyboardInput(event) {
+ return this._bufferEvents(this._onInput(event => event.key))(event);
+ }
+ _onNonKeyboardInput(event) {
+ return this._bufferEvents(this._onInput(event => event.detail.key))(event);
+ }
+ _bufferEvents(handler) {
+ return event => {
+ if (['INPUT', 'TEXTAREA'].includes(event.target.tagName) || !this.eventsBuffer) return;
+ clearTimeout(this._timeout);
+ this.eventsBuffer.push(event);
+ this._timeout = setTimeout(handler, this.maxTimeBetweenKeys);
+ this.handler = handler
+ };
+ }
+ _onInput(keyAccessor) {
+ return () => {
+ if (this.eventsBuffer.length <= 2) {
+ // Check first the buffer if its contents are all valid
+ // number input.
+ for (let event of this.eventsBuffer) {
+ if (!ALLOWED_KEYS.has(keyAccessor(event))) {
+ this.eventsBuffer = [];
+ return;
+ }
+ }
+ // At this point, all the events in buffer
+ // contains number input. It's now okay to handle
+ // each input.
+ for (let event of this.eventsBuffer) {
+ this._handleInput(keyAccessor(event));
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }
+ this.eventsBuffer = [];
+ };
+ }
+ _handleInput(key) {
+ if (key === 'Enter' && this.config.triggerAtEnter) {
+ this.component.trigger(this.config.triggerAtEnter, this.state);
+ } else if (key === 'Esc' && this.config.triggerAtEsc) {
+ this.component.trigger(this.config.triggerAtEsc, this.state);
+ } else if (INPUT_KEYS.has(key)) {
+ this._updateBuffer(key);
+ if (this.config.triggerAtInput)
+ this.component.trigger(this.config.triggerAtInput, { buffer: this.state.buffer, key });
+ }
+ }
+ /**
+ * Updates the current buffer state using the given input.
+ * @param {String} input valid input
+ */
+ _updateBuffer(input) {
+ const isEmpty = val => {
+ return val === '' || val === null;
+ };
+ if (input === undefined || input === null) return;
+ let isFirstInput = isEmpty(this.state.buffer);
+ if (input === ',' || input === '.') {
+ if (isFirstInput) {
+ this.state.buffer = '0' + this.decimalPoint;
+ } else if (!this.state.buffer.length || this.state.buffer === '-') {
+ this.state.buffer += '0' + this.decimalPoint;
+ } else if (this.state.buffer.indexOf(this.decimalPoint) < 0) {
+ this.state.buffer = this.state.buffer + this.decimalPoint;
+ }
+ } else if (input === 'Delete') {
+ if (this.isReset) {
+ this.state.buffer = '';
+ this.isReset = false;
+ return;
+ }
+ this.state.buffer = isEmpty(this.state.buffer) ? null : '';
+ } else if (input === 'Backspace') {
+ if (this.isReset) {
+ this.state.buffer = '';
+ this.isReset = false;
+ return;
+ }
+ const buffer = this.state.buffer;
+ if (isEmpty(buffer)) {
+ this.state.buffer = null;
+ } else {
+ const nCharToRemove = buffer[buffer.length - 1] === this.decimalPoint ? 2 : 1;
+ this.state.buffer = buffer.substring(0, buffer.length - nCharToRemove);
+ }
+ } else if (input === '+') {
+ if (this.state.buffer[0] === '-') {
+ this.state.buffer = this.state.buffer.substring(1, this.state.buffer.length);
+ }
+ } else if (input === '-') {
+ if (isFirstInput) {
+ this.state.buffer = '-0';
+ } else if (this.state.buffer[0] === '-') {
+ this.state.buffer = this.state.buffer.substring(1, this.state.buffer.length);
+ } else {
+ this.state.buffer = '-' + this.state.buffer;
+ }
+ } else if (input[0] === '+' && !isNaN(parseFloat(input))) {
+ // when input is like '+10', '+50', etc
+ const inputValue = parse.float(input.slice(1));
+ const currentBufferValue = this.state.buffer ? parse.float(this.state.buffer) : 0;
+ this.state.buffer = this.component.env.pos.formatFixed(
+ inputValue + currentBufferValue
+ );
+ } else if (!isNaN(parseInt(input, 10))) {
+ if (isFirstInput) {
+ this.state.buffer = '' + input;
+ } else if (this.state.buffer.length > 12) {
+ Gui.playSound('bell');
+ } else {
+ this.state.buffer += input;
+ }
+ }
+ if (this.state.buffer === '-') {
+ this.state.buffer = '';
+ }
+ // once an input is accepted and updated the buffer,
+ // the buffer should not be in reset state anymore.
+ this.isReset = false;
+
+ this.trigger('buffer-update', this.state.buffer);
+ }
+ }
+
+ return new NumberBuffer();
+});
diff --git a/addons/point_of_sale/static/src/js/Misc/SearchBar.js b/addons/point_of_sale/static/src/js/Misc/SearchBar.js
new file mode 100644
index 00000000..e9f56fea
--- /dev/null
+++ b/addons/point_of_sale/static/src/js/Misc/SearchBar.js
@@ -0,0 +1,115 @@
+odoo.define('point_of_sale.SearchBar', function (require) {
+ 'use strict';
+
+ const { useState, useExternalListener } = owl.hooks;
+ const PosComponent = require('point_of_sale.PosComponent');
+ const Registries = require('point_of_sale.Registries');
+
+ /**
+ * This is a simple configurable search bar component. It has search fields
+ * and selection filter. Search fields allow the users to specify the type
+ * of their searches. The filter is a dropdown menu for selection. Depending on
+ * user's action, this component emits corresponding event with the action
+ * information (payload).
+ *
+ * TODO: This component can be made more generic and be able to replace
+ * all the search bars across pos ui.
+ *
+ * @prop {{
+ * config: {
+ * searchFields: string[],
+ * filter: { show: boolean, options: string[] }
+ * },
+ * placeholder: string,
+ * }}
+ * @emits search @payload { fieldValue: string, searchTerm: '' }
+ * @emits filter-selected @payload { filter: string }
+ *
+ * NOTE: The payload of the emitted event is accessible via the `detail`
+ * field of the event.
+ */
+ class SearchBar extends PosComponent {
+ constructor() {
+ super(...arguments);
+ this.config = this.props.config;
+ this.state = useState({
+ searchInput: '',
+ selectedFieldId: this.config.searchFields.length ? 0 : null,
+ showSearchFields: false,
+ showFilterOptions: false,
+ selectedFilter: this.config.filter.options[0] || this.env._t('Select'),
+ });
+ useExternalListener(window, 'click', this._hideOptions);
+ }
+ selectFilter(option) {
+ this.state.selectedFilter = option;
+ this.trigger('filter-selected', { filter: this.state.selectedFilter });
+ }
+ get placeholder() {
+ return this.props.placeholder;
+ }
+ /**
+ * When vertical arrow keys are pressed, select fields for searching.
+ * When enter key is pressed, trigger search event if there is searchInput.
+ */
+ onKeydown(event) {
+ if (['ArrowUp', 'ArrowDown'].includes(event.key)) {
+ event.preventDefault();
+ this.state.selectedFieldId = this._fieldIdToSelect(event.key);
+ } else if (event.key === 'Enter') {
+ this.trigger('search', {
+ fieldValue: this.config.searchFields[this.state.selectedFieldId],
+ searchTerm: this.state.searchInput,
+ });
+ this.state.showSearchFields = false;
+ } else {
+ if (this.state.selectedFieldId === null && this.config.searchFields.length) {
+ this.state.selectedFieldId = 0;
+ }
+ this.state.showSearchFields = true;
+ }
+ }
+ /**
+ * Called when a search field is clicked.
+ */
+ onClickSearchField(id) {
+ this.state.showSearchFields = false;
+ this.trigger('search', {
+ fieldValue: this.config.searchFields[id],
+ searchTerm: this.state.searchInput,
+ });
+ }
+ /**
+ * Given an arrow key, return the next selectedFieldId.
+ * E.g. If the selectedFieldId is 1 and ArrowDown is pressed, return 2.
+ *
+ * @param {string} key vertical arrow key
+ */
+ _fieldIdToSelect(key) {
+ const length = this.config.searchFields.length;
+ if (!length) return null;
+ if (this.state.selectedFieldId === null) return 0;
+ const current = this.state.selectedFieldId || length;
+ return (current + (key === 'ArrowDown' ? 1 : -1)) % length;
+ }
+ _hideOptions() {
+ this.state.showFilterOptions = false;
+ this.state.showSearchFields = false;
+ }
+ }
+ SearchBar.template = 'point_of_sale.SearchBar';
+ SearchBar.defaultProps = {
+ config: {
+ searchFields: [],
+ filter: {
+ show: false,
+ options: [],
+ },
+ },
+ placeholder: 'Search ...',
+ };
+
+ Registries.Component.add(SearchBar);
+
+ return SearchBar;
+});