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; + }); });