diff --git a/api/resources/translations/messages-bm.properties b/api/resources/translations/messages-bm.properties
index 1a36eb59fad..8888d32bec8 100644
--- a/api/resources/translations/messages-bm.properties
+++ b/api/resources/translations/messages-bm.properties
@@ -373,6 +373,8 @@ app.version.unknown =
associated.contact =
associated.contact.help = Ni nin labaarabaga ye baara kɔmasegin kunafoniw da u bɛ tengu a tigi tɔgɔla.
autoreply = Jaabili yɔrɔbɛ
+barcode_scanner.error.cannot_read_barcode = Kɛrɛfɛ kɛmɛya la, i kɛmɛya.
+barcode_scanner.message.disable = Barcode scanner bɛ mɔgɔ la mɔgɔ kɔnɔ.
birth_date = Wolo Don/Kalo/San
branding =
branding.favicon.field =
diff --git a/api/resources/translations/messages-en.properties b/api/resources/translations/messages-en.properties
index 4962d4f1738..707575f24ce 100644
--- a/api/resources/translations/messages-en.properties
+++ b/api/resources/translations/messages-en.properties
@@ -381,6 +381,8 @@ app.version.unknown = Unknown - internet connection required.
associated.contact = Associated contact
associated.contact.help = When this user creates reports they will be assigned to this contact
autoreply = autoreply
+barcode_scanner.error.cannot_read_barcode = Failed to read the barcode. Retry.
+barcode_scanner.message.disable = The barcode scanner is not available on this device.
birth_date = Birth date
branding = Branding
branding.favicon.field = Small icon
diff --git a/api/resources/translations/messages-es.properties b/api/resources/translations/messages-es.properties
index bf8fc983850..a382080399a 100644
--- a/api/resources/translations/messages-es.properties
+++ b/api/resources/translations/messages-es.properties
@@ -381,6 +381,8 @@ app.version.unknown = Desconocido - se requiere conexión a Internet.
associated.contact = Contacto asociado
associated.contact.help = Cuando este usuario crea informes, serán asignados a este contacto.
autoreply = respuesta automática
+barcode_scanner.error.cannot_read_barcode = No se puede leer el código de barras. Vuelva a intentarlo.
+barcode_scanner.message.disable = El escáner de código de barras no está disponible en este dispositivo.
birth_date = fecha de nacimiento
branding = marca
branding.favicon.field = Icono pequeño
@@ -638,7 +640,7 @@ email.invalid = Dirección de correo electrónico no válida.
empty = El mensaje esta en blanco, por favor reenvielo. Si continua teniendo problemas, informe a su supervisor.
enketo.constraint.invalid = Valor no permitido
enketo.constraint.required = Este campo es obligatorio
-enketo.drawwidget.annotation = anotacin
+enketo.drawwidget.annotation = anotaci�n
enketo.drawwidget.drawing = dibujo
enketo.drawwidget.signature = firma
enketo.error.max_attachment_size = Los archivos subidos exceden el límite de tamaño total. Suba archivos más pequeños.
diff --git a/api/resources/translations/messages-fr.properties b/api/resources/translations/messages-fr.properties
index 92431b1333d..e28464f82aa 100644
--- a/api/resources/translations/messages-fr.properties
+++ b/api/resources/translations/messages-fr.properties
@@ -381,6 +381,8 @@ app.version.unknown = Inconnu – connexion Internet requise.
associated.contact = Contact associé
associated.contact.help = Lorsque cet utilisateur crée des rapport, ils seront assignés à ce contact
autoreply = auto-réponse
+barcode_scanner.error.cannot_read_barcode = Impossible de lire le code barre. Veuillez réessayer.
+barcode_scanner.message.disable = Le lecteur de code-barres n'est pas disponible sur cet appareil.
birth_date = Date de naissance
branding = Personnalisation
branding.favicon.field = Petite icône
diff --git a/api/resources/translations/messages-hi.properties b/api/resources/translations/messages-hi.properties
index 543e93dea36..4aaaa0921b8 100644
--- a/api/resources/translations/messages-hi.properties
+++ b/api/resources/translations/messages-hi.properties
@@ -373,6 +373,8 @@ app.version.unknown =
associated.contact = कॉंटेक्ट से जुड़ा हुआ
associated.contact.help = जब यह यूजर रिपोर्ट बनाएगा तो वे is कॉन्टैक्ट के साथ जोड़ लिए जाएंगे |
autoreply = स्वचालित जवाब
+barcode_scanner.error.cannot_read_barcode = बारकोड पढ़ने में विफल। पुनः प्रयास करें।
+barcode_scanner.message.disable = बारकोड स्कैनर इस डिवाइस पर उपलब्ध नहीं है।
birth_date = जन्म दिन
branding =
branding.favicon.field =
diff --git a/api/resources/translations/messages-id.properties b/api/resources/translations/messages-id.properties
index 1a503a96612..4c3371696c2 100644
--- a/api/resources/translations/messages-id.properties
+++ b/api/resources/translations/messages-id.properties
@@ -373,6 +373,8 @@ app.version.unknown =
associated.contact = Kontak yang berhubungan
associated.contact.help = Ketika pengguna ini membuat laporan, mereka akan dihubungkan kepada kontak ini
autoreply = jawab otomatis
+barcode_scanner.error.cannot_read_barcode = Gagal membaca barcode. Mencoba kembali.
+barcode_scanner.message.disable = Pemindai barcode tidak tersedia pada perangkat ini.
birth_date = Tanggal Lahir
branding =
branding.favicon.field =
diff --git a/api/resources/translations/messages-ne.properties b/api/resources/translations/messages-ne.properties
index 0ad3076b698..55ecdcc8691 100644
--- a/api/resources/translations/messages-ne.properties
+++ b/api/resources/translations/messages-ne.properties
@@ -377,6 +377,8 @@ app.version.unknown =
associated.contact = सम्बद्ध सम्पर्क
associated.contact.help = जब यो प्रयोगकर्ताले रिपोर्ट निर्माण गर्छ, तिनिहरु यो सम्पर्कमा निर्दिष्ट हुनेछन्
autoreply = स्वचालित जवाफ
+barcode_scanner.error.cannot_read_barcode = बारकोड पढ्न सकिएन। पुन: प्रयास गर्नुहोस्।
+barcode_scanner.message.disable = बारकोड स्क्यानर यस उपकरणमा उपलब्ध छैन।
birth_date = जन्म मिति
branding =
branding.favicon.field =
diff --git a/api/resources/translations/messages-sw.properties b/api/resources/translations/messages-sw.properties
index 2843aae8213..a503d3c6a0b 100644
--- a/api/resources/translations/messages-sw.properties
+++ b/api/resources/translations/messages-sw.properties
@@ -383,6 +383,8 @@ app.version.unknown = Haijulikani - muunganisho wa mtandao unahitajika
associated.contact = Nambari za mawasiliano zinazohusika
associated.contact.help = Mhusika huyu akitoa ripoti, zitahusishwa na mwenzi huyu
autoreply = ujumbe wa moja kwa moja
+barcode_scanner.error.cannot_read_barcode = Imeshindwa kusoma msimbo pau. Jaribu tena.
+barcode_scanner.message.disable = Kisomaji cha barcode haipatikani kwenye kifaa hiki.
birth_date = Tarehe ya kuzaliwa
branding = Chapa
branding.favicon.field = Ikoni ndogo
diff --git a/config/covid-19/app_settings.json b/config/covid-19/app_settings.json
index 4f906e06941..61d2bdcf84a 100644
--- a/config/covid-19/app_settings.json
+++ b/config/covid-19/app_settings.json
@@ -159,7 +159,8 @@
],
"can_aggregate_targets": [
"chw_supervisor"
- ]
+ ],
+ "can_search_with_barcode_scanner": []
},
"place_hierarchy_types": [
"district_hospital",
diff --git a/config/default/app_settings.json b/config/default/app_settings.json
index 29399fdd47f..d4dbb7381d8 100644
--- a/config/default/app_settings.json
+++ b/config/default/app_settings.json
@@ -286,7 +286,8 @@
"can_have_multiple_places": [],
"can_export_devices_details": [
"national_admin"
- ]
+ ],
+ "can_search_with_barcode_scanner": []
},
"uhc": {
"contacts_default_sort": "",
diff --git a/config/demo/app_settings.json b/config/demo/app_settings.json
index 67941d78315..af7d9898e24 100644
--- a/config/demo/app_settings.json
+++ b/config/demo/app_settings.json
@@ -277,7 +277,8 @@
"program_officer"
],
"can_view_old_filter_and_search": [],
- "can_view_old_action_bar": []
+ "can_view_old_action_bar": [],
+ "can_search_with_barcode_scanner": []
},
"uhc": {
"contacts_default_sort": "",
diff --git a/webapp/src/css/enketo/medic.less b/webapp/src/css/enketo/medic.less
index d64dd58455c..ba9858f7d21 100644
--- a/webapp/src/css/enketo/medic.less
+++ b/webapp/src/css/enketo/medic.less
@@ -443,6 +443,16 @@
.pages.or .or-repeat-info[role="page"] {
display: block;
}
+
+ .scan-barcode {
+ font-size: @font-XXXL;
+ line-height: @font-XXXL;
+ text-align: center;
+ vertical-align: middle;
+ .barcode-scanner-file {
+ display: none;
+ }
+ }
}
@media (max-width: @media-mobile) {
diff --git a/webapp/src/css/inbox.less b/webapp/src/css/inbox.less
index 54331e3a536..a88e993baad 100644
--- a/webapp/src/css/inbox.less
+++ b/webapp/src/css/inbox.less
@@ -1196,7 +1196,7 @@ mm-search-bar {
.search-bar-left-icon {
align-self: center;
- width: 18px;
+ min-width: 18px;
height: 20px;
margin: 0 15px;
}
@@ -1265,11 +1265,27 @@ mm-search-bar {
.fa {
font-size: @font-extra-large;
color: @filter-icon-color;
+ vertical-align: top;
+ &.fa-qrcode {
+ font-size: @font-extra-extra-large;
+ }
+
+ &.fa-sliders {
+ vertical-align: baseline;
+ }
+
+ &:not(:first-child) {
+ margin-left: 15px;
+ }
}
&.disabled .search-bar-left-icon .fa {
color: @inactive-color;
}
+
+ .barcode-scanner-input {
+ display: none;
+ }
}
}
diff --git a/webapp/src/css/variables.less b/webapp/src/css/variables.less
index 33b999b71fc..2d7df637c3a 100644
--- a/webapp/src/css/variables.less
+++ b/webapp/src/css/variables.less
@@ -100,6 +100,7 @@
@font-family-main: Noto, sans-serif;
/* font sizes */
+@font-XXXL: 2.5rem;
@font-extra-extra-large: 1.5rem;
@font-extra-large: 1.25rem;
@font-large: 1.125rem;
diff --git a/webapp/src/js/enketo/widgets.js b/webapp/src/js/enketo/widgets.js
index d37b694bcce..912e5aa9ddb 100644
--- a/webapp/src/js/enketo/widgets.js
+++ b/webapp/src/js/enketo/widgets.js
@@ -28,6 +28,7 @@
require( './widgets/bikram-sambat-datepicker' ),
require( './widgets/mrdt' ),
require( './widgets/android-app-launcher' ),
+ require( './widgets/barcode-scanner' ),
require( './widgets/display-base64-image' ),
require( './widgets/dynamic-url' ),
require( './widgets/draw' ),
diff --git a/webapp/src/js/enketo/widgets/barcode-scanner.js b/webapp/src/js/enketo/widgets/barcode-scanner.js
new file mode 100644
index 00000000000..c4fb35af716
--- /dev/null
+++ b/webapp/src/js/enketo/widgets/barcode-scanner.js
@@ -0,0 +1,54 @@
+'use strict';
+const Widget = require( 'enketo-core/src/js/widget' ).default;
+const $ = require('jquery');
+require('enketo-core/src/js/plugins');
+
+const APPEARANCES = {
+ widget: '.or-appearance-barcode-scanner',
+ input: '.or-appearance-barcode-input',
+};
+
+/**
+ * Barcode scanner
+ * @extends Widget
+ */
+class Barcodescannerwidget extends Widget {
+ static get selector() {
+ return APPEARANCES.widget;
+ }
+
+ async _init() {
+ const $widget = $(this.element);
+
+ const canScanBarcodes = await window.CHTCore.BarcodeScanner.canScanBarcodes();
+ if (!canScanBarcodes) {
+ window.CHTCore.Translate
+ .get('barcode_scanner.message.disable')
+ .then(label => $widget.append(``));
+ return;
+ }
+
+ const barcodeImageElement = await window.CHTCore.BarcodeScanner.initBarcodeScanner(barcodes => {
+ if (!barcodes || !barcodes.length) {
+ return;
+ }
+ $(`${APPEARANCES.input} input[type="text"]`)
+ .val(barcodes[0].rawValue)
+ .trigger('change');
+ });
+
+ $widget.append(
+ ``
+ );
+
+ $widget.on(
+ 'change',
+ '.barcode-scanner-file',
+ event => window.CHTCore.BarcodeScanner.processBarcodeFile(event.target, barcodeImageElement)
+ );
+ }
+}
+
+module.exports = Barcodescannerwidget;
diff --git a/webapp/src/ts/components/search-bar/search-bar.component.html b/webapp/src/ts/components/search-bar/search-bar.component.html
index f1782150e7c..747ed74b1c6 100644
--- a/webapp/src/ts/components/search-bar/search-bar.component.html
+++ b/webapp/src/ts/components/search-bar/search-bar.component.html
@@ -1,5 +1,8 @@
diff --git a/webapp/src/ts/components/search-bar/search-bar.component.ts b/webapp/src/ts/components/search-bar/search-bar.component.ts
index 749e237f3c2..26f4099abf3 100644
--- a/webapp/src/ts/components/search-bar/search-bar.component.ts
+++ b/webapp/src/ts/components/search-bar/search-bar.component.ts
@@ -15,6 +15,12 @@ import { Selectors } from '@mm-selectors/index';
import { FreetextFilterComponent } from '@mm-components/filters/freetext-filter/freetext-filter.component';
import { ResponsiveService } from '@mm-services/responsive.service';
import { SearchFiltersService } from '@mm-services/search-filters.service';
+import { AuthService } from '@mm-services/auth.service';
+import { SessionService } from '@mm-services/session.service';
+import { TelemetryService } from '@mm-services/telemetry.service';
+import { BarcodeScannerService } from '@mm-services/barcode-scanner.service';
+
+export const CAN_USE_BARCODE_SCANNER = 'can_search_with_barcode_scanner';
@Component({
selector: 'mm-search-bar',
@@ -24,31 +30,41 @@ export class SearchBarComponent implements AfterContentInit, AfterViewInit, OnDe
@Input() disabled;
@Input() showFilter;
@Input() showSort;
+ @Input() showBarcodeScanner;
@Input() sortDirection;
@Input() lastVisitedDateExtras;
@Output() sort: EventEmitter = new EventEmitter();
@Output() toggleFilter: EventEmitter = new EventEmitter();
@Output() search: EventEmitter = new EventEmitter();
+ private readonly TELEMETRY_PREFIX = 'search_by_barcode';
private filters;
+ private barcodeImageElement;
subscription: Subscription = new Subscription();
activeFilters: number = 0;
openSearch = false;
+ isBarcodeScannerAvailable = false;
@ViewChild(FreetextFilterComponent) freetextFilter?: FreetextFilterComponent;
constructor(
- private store: Store,
- private responsiveService: ResponsiveService,
- private searchFiltersService: SearchFiltersService,
- ) { }
+ private readonly store: Store,
+ private readonly responsiveService: ResponsiveService,
+ private readonly searchFiltersService: SearchFiltersService,
+ private readonly authService: AuthService,
+ private readonly sessionService: SessionService,
+ private readonly telemetryService: TelemetryService,
+ private readonly barcodeScannerService: BarcodeScannerService,
+ ) {}
ngAfterContentInit() {
this.subscribeToStore();
}
- ngAfterViewInit() {
+ async ngAfterViewInit() {
+ this.isBarcodeScannerAvailable = await this.canShowBarcodeScanner();
this.searchFiltersService.init(this.freetextFilter);
+ this.barcodeImageElement = await this.barcodeScannerService.initBarcodeScanner(codes => this.scanBarcode(codes));
}
private subscribeToStore() {
@@ -66,12 +82,21 @@ export class SearchBarComponent implements AfterContentInit, AfterViewInit, OnDe
this.subscription.add(subscription);
}
- clear() {
+ onBarcodeOpen() {
+ this.telemetryService.record(`${this.TELEMETRY_PREFIX}:open`);
+ }
+
+ processBarcodeFile($event) {
+ this.barcodeScannerService.processBarcodeFile($event.target, this.barcodeImageElement);
+ }
+
+ async clear() {
if (this.disabled) {
return;
}
this.freetextFilter?.clear(true);
this.toggleMobileSearch(false);
+ this.barcodeImageElement = await this.barcodeScannerService.initBarcodeScanner(codes => this.scanBarcode(codes));
}
toggleMobileSearch(forcedValue?) {
@@ -99,6 +124,28 @@ export class SearchBarComponent implements AfterContentInit, AfterViewInit, OnDe
return this.openSearch || !!this.filters?.search;
}
+ private async canShowBarcodeScanner() {
+ if (!this.showBarcodeScanner) {
+ return false;
+ }
+
+ const canUseBarcodeScanner = !this.sessionService.isAdmin() && await this.authService.has(CAN_USE_BARCODE_SCANNER);
+ if (!canUseBarcodeScanner) {
+ return false;
+ }
+
+ return await this.barcodeScannerService.canScanBarcodes();
+ }
+
+ private async scanBarcode(barcodes) {
+ if (!barcodes.length) {
+ return;
+ }
+
+ this.searchFiltersService.freetextSearch(barcodes[0].rawValue);
+ await this.telemetryService.record(`${this.TELEMETRY_PREFIX}:trigger_search`);
+ }
+
ngOnDestroy() {
this.subscription.unsubscribe();
}
diff --git a/webapp/src/ts/modules/contacts/contacts.component.html b/webapp/src/ts/modules/contacts/contacts.component.html
index 485fe094453..05b9b67a38c 100644
--- a/webapp/src/ts/modules/contacts/contacts.component.html
+++ b/webapp/src/ts/modules/contacts/contacts.component.html
@@ -14,6 +14,7 @@
[showSort]="isAllowedToSort"
[sortDirection]="sortDirection"
[lastVisitedDateExtras]="lastVisitedDateExtras"
+ [showBarcodeScanner]="true"
(sort)="sort($event)"
(search)="search()">
diff --git a/webapp/src/ts/services/barcode-scanner.service.ts b/webapp/src/ts/services/barcode-scanner.service.ts
new file mode 100644
index 00000000000..f40515d6959
--- /dev/null
+++ b/webapp/src/ts/services/barcode-scanner.service.ts
@@ -0,0 +1,101 @@
+import { Inject, Injectable } from '@angular/core';
+import { Store } from '@ngrx/store';
+import { DOCUMENT } from '@angular/common';
+
+import { TelemetryService } from '@mm-services/telemetry.service';
+import { BrowserDetectorService } from '@mm-services/browser-detector.service';
+import { TranslateService } from '@mm-services/translate.service';
+import { GlobalActions } from '@mm-actions/global';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class BarcodeScannerService {
+ private readonly windowRef;
+ private readonly TELEMETRY_PREFIX = 'barcode_scanner';
+ private readonly globalAction: GlobalActions;
+
+ constructor(
+ private readonly store: Store,
+ private readonly browserDetectorService: BrowserDetectorService,
+ private readonly telemetryService: TelemetryService,
+ private readonly translateService: TranslateService,
+ @Inject(DOCUMENT) private document: Document,
+ ) {
+ this.windowRef = this.document.defaultView;
+ this.globalAction = new GlobalActions(this.store);
+ }
+
+ async canScanBarcodes() {
+ const barcodeTypes = await this.getSupportedBarcodeFormats();
+
+ if (
+ !('BarcodeDetector' in this.windowRef)
+ || !barcodeTypes?.length
+ || this.browserDetectorService.isDesktopUserAgent() // CHT won't support it in desktop's browser.
+ ) {
+ const message = 'Barcode Detector API is not supported in this browser.';
+ console.error(message);
+ await this.telemetryService.record(`${this.TELEMETRY_PREFIX}:not_supported`);
+ return false;
+ }
+
+ return true;
+ }
+
+ async getSupportedBarcodeFormats() {
+ const barcodeTypes = await this.windowRef.BarcodeDetector?.getSupportedFormats();
+ console.info(`Supported barcode formats: ${barcodeTypes?.join(', ')}`);
+ return barcodeTypes;
+ }
+
+ async initBarcodeScanner(onScanCallback) {
+ const canScanBarcodes = await this.canScanBarcodes();
+ if (!canScanBarcodes) {
+ return;
+ }
+
+ const barcodeTypes = await this.getSupportedBarcodeFormats();
+ const barcodeDetector = new this.windowRef.BarcodeDetector({ formats: barcodeTypes });
+
+ const barcodeImageElement = this.windowRef.document.createElement('img');
+ barcodeImageElement?.addEventListener('load', () => {
+ this.scanBarcode(barcodeDetector, barcodeImageElement, onScanCallback);
+ });
+
+ return barcodeImageElement;
+ }
+
+ private async scanBarcode(barcodeDetector, imageHolder, onScanCallback) {
+ const errorMessageKey = 'barcode_scanner.error.cannot_read_barcode';
+ await this.telemetryService.record(`${this.TELEMETRY_PREFIX}:scan`);
+
+ try {
+ const barcodes = await barcodeDetector.detect(imageHolder);
+ if (barcodes.length) {
+ onScanCallback(barcodes);
+ return;
+ }
+
+ const message = this.translateService.instant(errorMessageKey);
+ this.globalAction.setSnackbarContent(message);
+ await this.telemetryService.record(`${this.TELEMETRY_PREFIX}:barcode_not_detected`);
+
+ } catch (error) {
+ const message = this.translateService.instant(errorMessageKey);
+ this.globalAction.setSnackbarContent(message);
+ console.error(message, error);
+ await this.telemetryService.record(`${this.TELEMETRY_PREFIX}:failure`);
+ }
+ }
+
+ processBarcodeFile(input, barcodeImageElement) {
+ if (!input.files) {
+ return;
+ }
+ const reader = new FileReader();
+ reader.addEventListener('load', event => barcodeImageElement.src = event?.target?.result);
+ reader.readAsDataURL(input.files[0]);
+ input.value = '';
+ }
+}
diff --git a/webapp/src/ts/services/browser-detector.service.ts b/webapp/src/ts/services/browser-detector.service.ts
index afbb6e28398..7b2484ccc60 100644
--- a/webapp/src/ts/services/browser-detector.service.ts
+++ b/webapp/src/ts/services/browser-detector.service.ts
@@ -59,4 +59,8 @@ export class BrowserDetectorService {
return this.androidAppVersion?.startsWith('v1.');
}
+
+ isDesktopUserAgent() {
+ return this.parser.getPlatformType(true) === 'desktop';
+ }
}
diff --git a/webapp/src/ts/services/integration-api.service.ts b/webapp/src/ts/services/integration-api.service.ts
index 0e4ef82a5c2..77bd858b114 100644
--- a/webapp/src/ts/services/integration-api.service.ts
+++ b/webapp/src/ts/services/integration-api.service.ts
@@ -9,12 +9,14 @@ import { AndroidApiService } from '@mm-services/android-api.service';
import { DbService } from '@mm-services/db.service';
import { EnketoService } from '@mm-services/enketo.service';
import { TranslateService } from '@mm-services/translate.service';
+import { BarcodeScannerService } from '@mm-services/barcode-scanner.service';
@Injectable({
providedIn: 'root'
})
export class IntegrationApiService {
AndroidAppLauncher;
+ BarcodeScanner;
Language;
Select2Search;
Enketo;
@@ -35,9 +37,11 @@ export class IntegrationApiService {
private mrdtService:MRDTService,
private settingsService:SettingsService,
private androidApiService:AndroidApiService,
+ private barcodeScannerService:BarcodeScannerService,
) {
this.DB = dbService;
this.AndroidAppLauncher = androidAppLauncherService;
+ this.BarcodeScanner = barcodeScannerService;
this.Language = languageService;
this.Select2Search = select2SearchService;
this.Enketo = enketoService;
diff --git a/webapp/tests/karma/ts/components/search-bar/search-bar.component.spec.ts b/webapp/tests/karma/ts/components/search-bar/search-bar.component.spec.ts
index 12cca3846e8..dc5ae220c59 100644
--- a/webapp/tests/karma/ts/components/search-bar/search-bar.component.spec.ts
+++ b/webapp/tests/karma/ts/components/search-bar/search-bar.component.spec.ts
@@ -1,3 +1,4 @@
+
import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing';
import { FormsModule } from '@angular/forms';
import sinon from 'sinon';
@@ -5,11 +6,15 @@ import { expect } from 'chai';
import { TranslateFakeLoader, TranslateLoader, TranslateModule } from '@ngx-translate/core';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
-import { SearchBarComponent } from '@mm-components/search-bar/search-bar.component';
+import { CAN_USE_BARCODE_SCANNER, SearchBarComponent } from '@mm-components/search-bar/search-bar.component';
import { FreetextFilterComponent } from '@mm-components/filters/freetext-filter/freetext-filter.component';
import { Selectors } from '@mm-selectors/index';
import { ResponsiveService } from '@mm-services/responsive.service';
import { SearchFiltersService } from '@mm-services/search-filters.service';
+import { AuthService } from '@mm-services/auth.service';
+import { SessionService } from '@mm-services/session.service';
+import { TelemetryService } from '@mm-services/telemetry.service';
+import { BarcodeScannerService } from '@mm-services/barcode-scanner.service';
describe('Search Bar Component', () => {
let component: SearchBarComponent;
@@ -17,16 +22,31 @@ describe('Search Bar Component', () => {
let store: MockStore;
let responsiveService;
let searchFiltersService;
+ let authService;
+ let sessionService;
+ let telemetryService;
+ let barcodeScannerService;
- beforeEach(() => {
+ beforeEach(async () => {
const mockedSelectors = [
{ selector: Selectors.getSidebarFilter, value: { filterCount: { total: 5 } } },
{ selector: Selectors.getFilters, value: undefined },
];
- searchFiltersService = { init: sinon.stub() };
+ searchFiltersService = {
+ init: sinon.stub(),
+ freetextSearch: sinon.stub(),
+ };
responsiveService = { isMobile: sinon.stub() };
-
- return TestBed
+ authService = { has: sinon.stub() };
+ sessionService = { isAdmin: sinon.stub() };
+ telemetryService = { record: sinon.stub() };
+ barcodeScannerService = {
+ initBarcodeScanner: sinon.stub(),
+ processBarcodeFile: sinon.stub(),
+ canScanBarcodes: sinon.stub(),
+ };
+
+ await TestBed
.configureTestingModule({
imports: [
TranslateModule.forRoot({ loader: { provide: TranslateLoader, useClass: TranslateFakeLoader } }),
@@ -40,17 +60,22 @@ describe('Search Bar Component', () => {
provideMockStore({ selectors: mockedSelectors }),
{ provide: ResponsiveService, useValue: responsiveService },
{ provide: SearchFiltersService, useValue: searchFiltersService },
+ { provide: AuthService, useValue: authService },
+ { provide: SessionService, useValue: sessionService },
+ { provide: TelemetryService, useValue: telemetryService },
+ { provide: BarcodeScannerService, useValue: barcodeScannerService },
]
})
- .compileComponents()
- .then(() => {
- fixture = TestBed.createComponent(SearchBarComponent);
- component = fixture.componentInstance;
- store = TestBed.inject(MockStore);
- fixture.detectChanges();
- });
+ .compileComponents();
+
+ fixture = TestBed.createComponent(SearchBarComponent);
+ component = fixture.componentInstance;
+ store = TestBed.inject(MockStore);
+ fixture.detectChanges();
});
+ afterEach(() => sinon.restore());
+
it('should create component', fakeAsync(() => {
flush();
expect(component).to.exist;
@@ -61,6 +86,7 @@ describe('Search Bar Component', () => {
sinon.resetHistory();
component.ngAfterViewInit();
+ flush();
expect(searchFiltersService.init.calledOnce).to.be.true;
}));
@@ -154,4 +180,131 @@ describe('Search Bar Component', () => {
tick();
expect(component.showClearIcon()).to.be.false;
}));
+
+ describe('Barcode scanner support', () => {
+ it('should return true if BarcodeDetector is supported, user has permission and is not admin', async () => {
+ sessionService.isAdmin.returns(false);
+ authService.has.resolves(true);
+ barcodeScannerService.canScanBarcodes.resolves(true);
+ component.showBarcodeScanner = true;
+ sinon.resetHistory();
+
+ await component.ngAfterViewInit();
+
+ expect(component.isBarcodeScannerAvailable).to.be.true;
+ expect(sessionService.isAdmin.calledOnce).to.be.true;
+ expect(barcodeScannerService.canScanBarcodes.calledOnce).to.be.true;
+ expect(authService.has.calledOnce).to.be.true;
+ expect(authService.has.args[0]).to.have.members([ CAN_USE_BARCODE_SCANNER ]);
+ });
+
+ it('should return false if barcode scanner is configured to not show', async () => {
+ sessionService.isAdmin.returns(false);
+ authService.has.resolves(true);
+ barcodeScannerService.canScanBarcodes.resolves(true);
+ component.showBarcodeScanner = false;
+ sinon.resetHistory();
+
+ await component.ngAfterViewInit();
+
+ expect(component.isBarcodeScannerAvailable).to.be.false;
+ expect(barcodeScannerService.canScanBarcodes.notCalled).to.be.true;
+ expect(sessionService.isAdmin.notCalled).to.be.true;
+ expect(authService.has.notCalled).to.be.true;
+ });
+
+ it('should return false if user does not have permission', async () => {
+ sessionService.isAdmin.returns(false);
+ authService.has.resolves(false);
+ barcodeScannerService.canScanBarcodes.resolves(true);
+ component.showBarcodeScanner = true;
+ sinon.resetHistory();
+
+ await component.ngAfterViewInit();
+
+ expect(component.isBarcodeScannerAvailable).to.be.false;
+ expect(barcodeScannerService.canScanBarcodes.notCalled).to.be.true;
+ expect(sessionService.isAdmin.calledOnce).to.be.true;
+ expect(authService.has.calledOnce).to.be.true;
+ expect(authService.has.args[0]).to.have.members([ CAN_USE_BARCODE_SCANNER ]);
+ });
+
+ it('should return false if user is admin', async () => {
+ sessionService.isAdmin.returns(true);
+ authService.has.resolves(true);
+ barcodeScannerService.canScanBarcodes.resolves(true);
+ component.showBarcodeScanner = true;
+ sinon.resetHistory();
+
+ await component.ngAfterViewInit();
+
+ expect(barcodeScannerService.canScanBarcodes.notCalled).to.be.true;
+ expect(component.isBarcodeScannerAvailable).to.be.false;
+ expect(sessionService.isAdmin.calledOnce).to.be.true;
+ });
+
+ it('should return false if BarcodeDetector is not supported', async () => {
+ sessionService.isAdmin.returns(false);
+ authService.has.resolves(true);
+ barcodeScannerService.canScanBarcodes.resolves(false);
+ sinon.resetHistory();
+ component.showBarcodeScanner = true;
+
+ await component.ngAfterViewInit();
+
+ expect(barcodeScannerService.canScanBarcodes.calledOnce).to.be.true;
+ expect(component.isBarcodeScannerAvailable).to.be.false;
+ });
+ });
+
+ describe('Scan barcodes', () => {
+ it('should scan barcode and trigger search', fakeAsync(async () => {
+ sessionService.isAdmin.returns(false);
+ authService.has.resolves(true);
+ barcodeScannerService.canScanBarcodes.resolves(true);
+ barcodeScannerService.initBarcodeScanner.resolves();
+ component.showBarcodeScanner = true;
+ sinon.resetHistory();
+
+ await component.ngAfterViewInit();
+
+ const callback = barcodeScannerService.initBarcodeScanner.args[0][0];
+ callback([{ rawValue: '1234' }]);
+ flush();
+
+ expect(telemetryService.record.calledWith('search_by_barcode:trigger_search')).to.be.true;
+ expect(searchFiltersService.freetextSearch.calledWith('1234')).to.be.true;
+ }));
+
+ it('should not trigger search if no barcodes', fakeAsync(async () => {
+ sessionService.isAdmin.returns(false);
+ authService.has.resolves(true);
+ barcodeScannerService.canScanBarcodes.resolves(true);
+ barcodeScannerService.initBarcodeScanner.resolves();
+ component.showBarcodeScanner = true;
+ sinon.resetHistory();
+
+ await component.ngAfterViewInit();
+
+ const callback = barcodeScannerService.initBarcodeScanner.args[0][0];
+ callback([]);
+ flush();
+
+ expect(telemetryService.record.notCalled).to.be.true;
+ expect(searchFiltersService.freetextSearch.notCalled).to.be.true;
+ }));
+
+ it('should record telemetry when barcode is clicked.', fakeAsync(async () => {
+ sessionService.isAdmin.returns(false);
+ authService.has.resolves(true);
+ barcodeScannerService.canScanBarcodes.resolves(true);
+ component.showBarcodeScanner = true;
+ sinon.resetHistory();
+
+ component.onBarcodeOpen();
+ flush();
+
+ expect(telemetryService.record.calledWith('search_by_barcode:open')).to.be.true;
+ }));
+ });
});
diff --git a/webapp/tests/karma/ts/modules/contacts/contacts.component.spec.ts b/webapp/tests/karma/ts/modules/contacts/contacts.component.spec.ts
index dc773ae7e15..0cc2e5cc429 100644
--- a/webapp/tests/karma/ts/modules/contacts/contacts.component.spec.ts
+++ b/webapp/tests/karma/ts/modules/contacts/contacts.component.spec.ts
@@ -34,6 +34,7 @@ import { ContactsMoreMenuComponent } from '@mm-modules/contacts/contacts-more-me
import { FastActionButtonComponent } from '@mm-components/fast-action-button/fast-action-button.component';
import { SearchBarComponent } from '@mm-components/search-bar/search-bar.component';
import { PerformanceService } from '@mm-services/performance.service';
+import { DbService } from '@mm-services/db.service';
describe('Contacts component', () => {
let searchResults;
@@ -161,6 +162,7 @@ describe('Contacts component', () => {
{ provide: MatBottomSheet, useValue: { open: sinon.stub() } },
{ provide: PerformanceService, useValue: performanceService },
{ provide: MatDialog, useValue: { open: sinon.stub() } },
+ { provide: DbService, useValue: {} },
]
})
.compileComponents().then(() => {
diff --git a/webapp/tests/karma/ts/services/barcode-scanner.service.spec.ts b/webapp/tests/karma/ts/services/barcode-scanner.service.spec.ts
new file mode 100644
index 00000000000..2a1cf9f7b2f
--- /dev/null
+++ b/webapp/tests/karma/ts/services/barcode-scanner.service.spec.ts
@@ -0,0 +1,192 @@
+import { fakeAsync, flush, TestBed } from '@angular/core/testing';
+import { DOCUMENT } from '@angular/common';
+import sinon from 'sinon';
+import { expect } from 'chai';
+import { TranslateFakeLoader, TranslateLoader, TranslateModule } from '@ngx-translate/core';
+import { provideMockStore } from '@ngrx/store/testing';
+
+import { GlobalActions } from '@mm-actions/global';
+import { TranslateService } from '@mm-services/translate.service';
+import { TelemetryService } from '@mm-services/telemetry.service';
+import { BrowserDetectorService } from '@mm-services/browser-detector.service';
+import { BarcodeScannerService } from '@mm-services/barcode-scanner.service';
+
+class BarcodeDetector {
+ constructor() {}
+ static getSupportedFormats() {}
+ detect() {}
+}
+
+describe('BarcodeScannerService', () => {
+ let service;
+ let translateService;
+ let telemetryService;
+ let documentRef;
+ let getSupportedFormatsStub;
+ let detectStub;
+ let browserDetectorService;
+
+ beforeEach(() => {
+ translateService = { instant: sinon.stub() };
+ telemetryService = { record: sinon.stub() };
+ browserDetectorService = { isDesktopUserAgent: sinon.stub() };
+
+ TestBed.configureTestingModule({
+ imports: [
+ TranslateModule.forRoot({ loader: { provide: TranslateLoader, useClass: TranslateFakeLoader } }),
+ ],
+ providers: [
+ provideMockStore({}),
+ { provide: TranslateService, useValue: translateService },
+ { provide: TelemetryService, useValue: telemetryService },
+ { provide: BrowserDetectorService, useValue: browserDetectorService },
+ ],
+ });
+
+ service = TestBed.inject(BarcodeScannerService);
+ documentRef = TestBed.inject(DOCUMENT);
+ service.windowRef = {
+ ...service.windowRef,
+ BarcodeDetector
+ };
+ getSupportedFormatsStub = sinon.stub(BarcodeDetector, 'getSupportedFormats').resolves([]);
+ detectStub = sinon.stub(BarcodeDetector.prototype, 'detect');
+ });
+
+ afterEach(() => sinon.restore());
+
+ describe('Barcode scanner support', () => {
+ it('should return true if BarcodeDetector is supported', async () => {
+ browserDetectorService.isDesktopUserAgent.returns(false);
+ getSupportedFormatsStub.resolves([ 'code_39', 'aztec' ]);
+
+ const result = await service.canScanBarcodes();
+
+ expect(result).to.be.true;
+ expect(browserDetectorService.isDesktopUserAgent.called).to.be.true;
+ });
+
+ it('should return false if browser is desktop', async () => {
+ browserDetectorService.isDesktopUserAgent.returns(true);
+ getSupportedFormatsStub.resolves([ 'code_39', 'aztec' ]);
+ sinon.resetHistory();
+
+ const result = await service.canScanBarcodes();
+
+ expect(result).to.be.false;
+ expect(browserDetectorService.isDesktopUserAgent.called).to.be.true;
+ expect(telemetryService.record.calledWith('barcode_scanner:not_supported')).to.be.true;
+ });
+
+ it('should return false if BarcodeDetector is not supported in "window"', async () => {
+ service.windowRef = {};
+ browserDetectorService.isDesktopUserAgent.returns(false);
+ getSupportedFormatsStub.resolves([ 'code_39', 'aztec' ]);
+ sinon.resetHistory();
+
+ const result = await service.canScanBarcodes();
+
+ expect(result).to.be.false;
+ expect(browserDetectorService.isDesktopUserAgent.called).to.be.false;
+ expect(telemetryService.record.calledWith('barcode_scanner:not_supported')).to.be.true;
+ });
+
+ it('should return false if browser does not support any type of barcode', async () => {
+ browserDetectorService.isDesktopUserAgent.returns(false);
+ getSupportedFormatsStub.resolves([]);
+
+ const result = await service.canScanBarcodes();
+
+ expect(result).to.be.false;
+ expect(browserDetectorService.isDesktopUserAgent.called).to.be.false;
+ expect(telemetryService.record.calledWith('barcode_scanner:not_supported')).to.be.true;
+ });
+ });
+
+ describe('Scan barcodes', () => {
+ it('should scan barcode', fakeAsync(async () => {
+ const imageHolder = { addEventListener: sinon.stub() };
+ getSupportedFormatsStub.resolves([ 'code_39', 'aztec' ]);
+ const createElementStub = sinon.stub(documentRef.defaultView.document, 'createElement');
+ createElementStub.returns(imageHolder);
+ detectStub.resolves([{ rawValue: '1234' }]);
+ const callback = sinon.stub();
+
+ const image = await service.initBarcodeScanner(callback);
+
+ expect(image).to.be.not.undefined;
+ expect(getSupportedFormatsStub.calledTwice).to.be.true;
+ expect(imageHolder.addEventListener.calledOnce).to.be.true;
+ expect(imageHolder.addEventListener.args[0][0]).to.equal('load');
+
+ const eventCallback = imageHolder.addEventListener.args[0][1];
+ eventCallback();
+ flush();
+
+ expect(telemetryService.record.calledWith('barcode_scanner:scan')).to.be.true;
+ expect(detectStub.calledWith(imageHolder)).to.be.true;
+ expect(callback.calledOnce).to.be.true;
+ expect(callback.args[0][0]).to.have.deep.members([{ rawValue: '1234' }]);
+ }));
+
+ it('should advice to retry if barcode was not detected', fakeAsync(async () => {
+ translateService.instant.returns('please retry');
+ getSupportedFormatsStub.resolves([ 'code_39', 'aztec' ]);
+ const setSnackbarContentSpy = sinon.spy(GlobalActions.prototype, 'setSnackbarContent');
+ const imageHolder = { addEventListener: sinon.stub() };
+ const createElementStub = sinon.stub(documentRef.defaultView.document, 'createElement');
+ createElementStub.returns(imageHolder);
+ detectStub.resolves([]);
+ sinon.resetHistory();
+ const callback = sinon.stub();
+
+ const image = await service.initBarcodeScanner(callback);
+
+ expect(image).to.be.not.undefined;
+ expect(getSupportedFormatsStub.calledTwice).to.be.true;
+ expect(imageHolder.addEventListener.calledOnce).to.be.true;
+ expect(imageHolder.addEventListener.args[0][0]).to.equal('load');
+
+ const eventCallback = imageHolder.addEventListener.args[0][1];
+ eventCallback();
+ flush();
+
+ expect(telemetryService.record.calledWith('barcode_scanner:scan')).to.be.true;
+ expect(telemetryService.record.calledWith('barcode_scanner:barcode_not_detected')).to.be.true;
+ expect(detectStub.calledWith(imageHolder)).to.be.true;
+ expect(translateService.instant.calledWith('barcode_scanner.error.cannot_read_barcode')).to.be.true;
+ expect(setSnackbarContentSpy.calledWith('please retry')).to.be.true;
+ expect(callback.notCalled).to.be.true;
+ }));
+
+ it('should catch exceptions', fakeAsync(async () => {
+ translateService.instant.returns('some nice text');
+ getSupportedFormatsStub.resolves([ 'code_39', 'aztec' ]);
+ const setSnackbarContentSpy = sinon.spy(GlobalActions.prototype, 'setSnackbarContent');
+ const imageHolder = { addEventListener: sinon.stub() };
+ const createElementStub = sinon.stub(documentRef.defaultView.document, 'createElement');
+ createElementStub.returns(imageHolder);
+ detectStub.rejects('some error');
+ sinon.resetHistory();
+ const callback = sinon.stub();
+
+ const image = await service.initBarcodeScanner(callback);
+
+ expect(image).to.be.not.undefined;
+ expect(getSupportedFormatsStub.calledTwice).to.be.true;
+ expect(imageHolder.addEventListener.calledOnce).to.be.true;
+ expect(imageHolder.addEventListener.args[0][0]).to.equal('load');
+
+ const eventCallback = imageHolder.addEventListener.args[0][1];
+ eventCallback();
+ flush();
+
+ expect(telemetryService.record.calledWith('barcode_scanner:scan')).to.be.true;
+ expect(detectStub.calledWith(imageHolder)).to.be.true;
+ expect(translateService.instant.calledWith('barcode_scanner.error.cannot_read_barcode')).to.be.true;
+ expect(setSnackbarContentSpy.calledWith('some nice text')).to.be.true;
+ expect(callback.notCalled).to.be.true;
+ expect(telemetryService.record.calledWith('barcode_scanner:failure')).to.be.true;
+ }));
+ });
+});
diff --git a/webapp/tests/karma/ts/services/browser-detector.service.spec.ts b/webapp/tests/karma/ts/services/browser-detector.service.spec.ts
index eec027ba3fd..459ae5c3bba 100644
--- a/webapp/tests/karma/ts/services/browser-detector.service.spec.ts
+++ b/webapp/tests/karma/ts/services/browser-detector.service.spec.ts
@@ -102,4 +102,22 @@ describe('Browser Detector Service', () => {
expect(service.isUsingOutdatedBrowser()).to.be.true;
});
+
+ it('should return true if platform type is desktop', () => {
+ spoofUserAgent(
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36' +
+ ' (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36'
+ );
+
+ expect(service.isDesktopUserAgent()).to.be.true;
+ });
+
+ it('should return false if platform type is not desktop', () => {
+ spoofUserAgent(
+ 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006)' +
+ ' AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Mobile Safari/537.36'
+ );
+
+ expect(service.isDesktopUserAgent()).to.be.false;
+ });
});