From 0bf582d1a57d8b539b09bd03f1f77a323dd91101 Mon Sep 17 00:00:00 2001 From: latin-panda <66472237+latin-panda@users.noreply.github.com> Date: Sat, 12 Oct 2024 15:15:12 +0700 Subject: [PATCH 01/10] search with barcode feature bump up from 4.4.1 to 4.9.0 --- .../translations/messages-bm.properties | 2 + .../translations/messages-en.properties | 2 + .../translations/messages-es.properties | 4 +- .../translations/messages-fr.properties | 2 + .../translations/messages-hi.properties | 2 + .../translations/messages-id.properties | 2 + .../translations/messages-ne.properties | 2 + .../translations/messages-sw.properties | 2 + config/covid-19/app_settings.json | 3 +- config/default/app_settings.json | 3 +- config/demo/app_settings.json | 3 +- webapp/src/css/inbox.less | 18 +- .../search-bar/search-bar.component.html | 3 + .../search-bar/search-bar.component.ts | 115 ++++++- .../modules/contacts/contacts.component.html | 1 + .../ts/services/browser-detector.service.ts | 4 + .../search-bar/search-bar.component.spec.ts | 298 +++++++++++++++++- .../contacts/contacts.component.spec.ts | 2 + .../services/browser-detector.service.spec.ts | 18 ++ 19 files changed, 466 insertions(+), 20 deletions(-) diff --git a/api/resources/translations/messages-bm.properties b/api/resources/translations/messages-bm.properties index 1a36eb59fad..e1cb55a05ce 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.label.scan = kɛrɛfɛ 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..9e1e235d331 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.label.scan = Scan 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..ac8f9566689 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.label.scan = Escanear 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..dfe8b8be7a7 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.label.scan = Scanner 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..31860d78154 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.label.scan = स्कैन करना birth_date = जन्म दिन branding = branding.favicon.field = diff --git a/api/resources/translations/messages-id.properties b/api/resources/translations/messages-id.properties index 1a503a96612..1325c174061 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.label.scan = Memindai 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..19f4201bb50 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.label.scan = स्क्यान गर्नु birth_date = जन्म मिति branding = branding.favicon.field = diff --git a/api/resources/translations/messages-sw.properties b/api/resources/translations/messages-sw.properties index 2843aae8213..2146c0aa041 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.label.scan = Kuchanganua 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/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/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..da1067722f2 100644 --- a/webapp/src/ts/components/search-bar/search-bar.component.ts +++ b/webapp/src/ts/components/search-bar/search-bar.component.ts @@ -7,14 +7,25 @@ import { AfterViewInit, Output, ViewChild, + Inject, } from '@angular/core'; import { Store } from '@ngrx/store'; import { combineLatest, Subscription } from 'rxjs'; +import { DOCUMENT } from '@angular/common'; 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 { 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 { FeedbackService } from '@mm-services/feedback.service'; + +export const CAN_USE_BARCODE_SCANNER = 'can_search_with_barcode_scanner'; @Component({ selector: 'mm-search-bar', @@ -24,16 +35,24 @@ 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 globalAction: GlobalActions; + private barcodeDetector; private filters; + private barcodeTypes; + private barcodeImageElement; + windowRef; subscription: Subscription = new Subscription(); activeFilters: number = 0; openSearch = false; + isBarcodeScannerAvailable = false; @ViewChild(FreetextFilterComponent) freetextFilter?: FreetextFilterComponent; @@ -41,14 +60,26 @@ export class SearchBarComponent implements AfterContentInit, AfterViewInit, OnDe private store: Store, private responsiveService: ResponsiveService, private searchFiltersService: SearchFiltersService, - ) { } + private authService: AuthService, + private sessionService: SessionService, + private translateService: TranslateService, + private telemetryService: TelemetryService, + private browserDetectorService: BrowserDetectorService, + private feedbackService: FeedbackService, + @Inject(DOCUMENT) private document: Document, + ) { + this.windowRef = this.document.defaultView; + this.globalAction = new GlobalActions(store); + } ngAfterContentInit() { this.subscribeToStore(); } - ngAfterViewInit() { + async ngAfterViewInit() { + this.isBarcodeScannerAvailable = await this.canShowBarcodeScanner(); this.searchFiltersService.init(this.freetextFilter); + await this.initBarcodeScanner(); } private subscribeToStore() { @@ -66,12 +97,40 @@ export class SearchBarComponent implements AfterContentInit, AfterViewInit, OnDe this.subscription.add(subscription); } + private async initBarcodeScanner() { + if (!this.isBarcodeScannerAvailable) { + return; + } + + console.info(`Supported barcode formats: ${this.barcodeTypes?.join(', ')}`); + this.barcodeDetector = new this.windowRef.BarcodeDetector({ formats: this.barcodeTypes }); + + this.barcodeImageElement = this.windowRef.document.createElement('img'); + this.barcodeImageElement?.addEventListener('load', () => this.scanBarcode(this.barcodeImageElement)); // NOSONAR + } + + onBarcodeOpen() { + this.telemetryService.record(`${this.TELEMETRY_PREFIX}:open`); + } + + processBarcodeFile($event) { + const input = $event.target; + if (!input.files) { + return; + } + const reader = new FileReader(); + reader.addEventListener('load', event => this.barcodeImageElement.src = event?.target?.result); + reader.readAsDataURL(input.files[0]); + input.value = ''; + } + clear() { if (this.disabled) { return; } this.freetextFilter?.clear(true); this.toggleMobileSearch(false); + this.initBarcodeScanner(); } toggleMobileSearch(forcedValue?) { @@ -99,6 +158,58 @@ 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; + } + + this.barcodeTypes = await this.windowRef.BarcodeDetector?.getSupportedFormats(); + + if ( + !('BarcodeDetector' in this.windowRef) + || !this.barcodeTypes?.length + || this.browserDetectorService.isDesktopUserAgent() // But we won't support it in desktop's browser. + ) { + const message = 'Barcode Detector API is not supported in this browser.'; + console.error(message); + this.feedbackService.submit(message); + this.telemetryService.record(`${this.TELEMETRY_PREFIX}:not_supported`); + return false; + } + + return true; + } + + private async scanBarcode(imageHolder) { + const errorMessageKey = 'barcode_scanner.error.cannot_read_barcode'; + this.telemetryService.record(`${this.TELEMETRY_PREFIX}:scan`); + + try { + const barcodes = await this.barcodeDetector.detect(imageHolder); + if (barcodes.length) { + this.searchFiltersService.freetextSearch(barcodes[0].rawValue); + this.telemetryService.record(`${this.TELEMETRY_PREFIX}:trigger_search`); + return; + } + + const message = this.translateService.instant(errorMessageKey); + this.globalAction.setSnackbarContent(message); + 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); + this.feedbackService.submit(message); + this.telemetryService.record(`${this.TELEMETRY_PREFIX}:failure`); + } + } + 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/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/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..b6fce810e01 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 @@ -4,12 +4,26 @@ import sinon from 'sinon'; import { expect } from 'chai'; import { TranslateFakeLoader, TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { DOCUMENT } from '@angular/common'; -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 { TranslateService } from '@mm-services/translate.service'; +import { TelemetryService } from '@mm-services/telemetry.service'; +import { GlobalActions } from '@mm-actions/global'; +import { BrowserDetectorService } from '@mm-services/browser-detector.service'; +import { FeedbackService } from '@mm-services/feedback.service'; + +class BarcodeDetector { + constructor() {} + static getSupportedFormats() {} + detect() {} +} describe('Search Bar Component', () => { let component: SearchBarComponent; @@ -17,16 +31,34 @@ describe('Search Bar Component', () => { let store: MockStore; let responsiveService; let searchFiltersService; - - beforeEach(() => { + let authService; + let sessionService; + let translateService; + let telemetryService; + let documentRef; + let getSupportedFormatsStub; + let detectStub; + let browserDetectorService; + let feedbackService; + + 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() }; + translateService = { instant: sinon.stub() }; + telemetryService = { record: sinon.stub() }; + browserDetectorService = { isDesktopUserAgent: sinon.stub() }; + feedbackService = { submit: sinon.stub() }; + + await TestBed .configureTestingModule({ imports: [ TranslateModule.forRoot({ loader: { provide: TranslateLoader, useClass: TranslateFakeLoader } }), @@ -40,17 +72,32 @@ describe('Search Bar Component', () => { provideMockStore({ selectors: mockedSelectors }), { provide: ResponsiveService, useValue: responsiveService }, { provide: SearchFiltersService, useValue: searchFiltersService }, + { provide: AuthService, useValue: authService }, + { provide: SessionService, useValue: sessionService }, + { provide: TranslateService, useValue: translateService }, + { provide: TelemetryService, useValue: telemetryService }, + { provide: BrowserDetectorService, useValue: browserDetectorService }, + { provide: FeedbackService, useValue: feedbackService }, ] }) - .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); + documentRef = TestBed.inject(DOCUMENT); + fixture.detectChanges(); + + component.windowRef = { + ...component.windowRef, + BarcodeDetector + }; + getSupportedFormatsStub = sinon.stub(BarcodeDetector, 'getSupportedFormats').resolves([]); + detectStub = sinon.stub(BarcodeDetector.prototype, 'detect'); }); + afterEach(() => sinon.restore()); + it('should create component', fakeAsync(() => { flush(); expect(component).to.exist; @@ -61,6 +108,7 @@ describe('Search Bar Component', () => { sinon.resetHistory(); component.ngAfterViewInit(); + flush(); expect(searchFiltersService.init.calledOnce).to.be.true; })); @@ -154,4 +202,228 @@ 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); + browserDetectorService.isDesktopUserAgent.returns(false); + getSupportedFormatsStub.resolves([ 'code_39', 'aztec' ]); + component.showBarcodeScanner = true; + sinon.resetHistory(); + + await component.ngAfterViewInit(); + + expect(component.isBarcodeScannerAvailable).to.be.true; + expect(sessionService.isAdmin.calledOnce).to.be.true; + expect(browserDetectorService.isDesktopUserAgent.called).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); + browserDetectorService.isDesktopUserAgent.returns(false); + getSupportedFormatsStub.resolves([ 'code_39', 'aztec' ]); + component.showBarcodeScanner = false; + sinon.resetHistory(); + + await component.ngAfterViewInit(); + + expect(component.isBarcodeScannerAvailable).to.be.false; + expect(browserDetectorService.isDesktopUserAgent.notCalled).to.be.true; + expect(sessionService.isAdmin.notCalled).to.be.true; + expect(authService.has.notCalled).to.be.true; + }); + + it('should return false if browser is desktop', async () => { + sessionService.isAdmin.returns(false); + authService.has.resolves(true); + browserDetectorService.isDesktopUserAgent.returns(true); + getSupportedFormatsStub.resolves([ 'code_39', 'aztec' ]); + component.showBarcodeScanner = true; + sinon.resetHistory(); + + await component.ngAfterViewInit(); + + expect(component.isBarcodeScannerAvailable).to.be.false; + expect(sessionService.isAdmin.calledOnce).to.be.true; + expect(browserDetectorService.isDesktopUserAgent.called).to.be.true; + expect(authService.has.calledOnce).to.be.true; + expect(authService.has.args[0]).to.have.members([ CAN_USE_BARCODE_SCANNER ]); + expect(feedbackService.submit.calledWith('Barcode Detector API is not supported in this browser.')).to.be.true; + expect(telemetryService.record.calledWith('search_by_barcode:not_supported')).to.be.true; + }); + + it('should return false if user does not have permission', async () => { + sessionService.isAdmin.returns(false); + authService.has.resolves(false); + browserDetectorService.isDesktopUserAgent.returns(false); + getSupportedFormatsStub.resolves([ 'code_39', 'aztec' ]); + component.showBarcodeScanner = true; + sinon.resetHistory(); + + await component.ngAfterViewInit(); + + expect(component.isBarcodeScannerAvailable).to.be.false; + expect(browserDetectorService.isDesktopUserAgent.called).to.be.false; + 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); + browserDetectorService.isDesktopUserAgent.returns(false); + getSupportedFormatsStub.resolves([ 'code_39', 'aztec' ]); + component.showBarcodeScanner = true; + sinon.resetHistory(); + + await component.ngAfterViewInit(); + + expect(browserDetectorService.isDesktopUserAgent.called).to.be.false; + 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); + browserDetectorService.isDesktopUserAgent.returns(false); + getSupportedFormatsStub.resolves([ 'code_39', 'aztec' ]); + sinon.resetHistory(); + component.showBarcodeScanner = true; + component.windowRef = {}; + + await component.ngAfterViewInit(); + + expect(browserDetectorService.isDesktopUserAgent.called).to.be.false; + expect(component.isBarcodeScannerAvailable).to.be.false; + expect(feedbackService.submit.calledWith('Barcode Detector API is not supported in this browser.')).to.be.true; + expect(telemetryService.record.calledWith('search_by_barcode:not_supported')).to.be.true; + }); + + it('should return false if browser does not support any type of barcode', async () => { + sessionService.isAdmin.returns(false); + authService.has.resolves(true); + browserDetectorService.isDesktopUserAgent.returns(false); + getSupportedFormatsStub.resolves([]); + component.showBarcodeScanner = true; + sinon.resetHistory(); + + await component.ngAfterViewInit(); + + expect(browserDetectorService.isDesktopUserAgent.called).to.be.false; + expect(component.isBarcodeScannerAvailable).to.be.false; + expect(feedbackService.submit.calledWith('Barcode Detector API is not supported in this browser.')).to.be.true; + expect(telemetryService.record.calledWith('search_by_barcode:not_supported')).to.be.true; + }); + }); + + describe('Scan barcodes', () => { + it('should scan barcode and trigger search', fakeAsync(async () => { + sessionService.isAdmin.returns(false); + authService.has.resolves(true); + 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' }]); + component.showBarcodeScanner = true; + sinon.resetHistory(); + + await component.ngAfterViewInit(); + + expect(getSupportedFormatsStub.calledOnce).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('search_by_barcode:scan')).to.be.true; + expect(telemetryService.record.calledWith('search_by_barcode:trigger_search')).to.be.true; + expect(detectStub.calledWith(imageHolder)).to.be.true; + expect(searchFiltersService.freetextSearch.calledWith('1234')).to.be.true; + })); + + it('should advice to retry if barcode was not detected', fakeAsync(async () => { + sessionService.isAdmin.returns(false); + authService.has.resolves(true); + 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([]); + component.showBarcodeScanner = true; + sinon.resetHistory(); + + await component.ngAfterViewInit(); + + expect(getSupportedFormatsStub.calledOnce).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('search_by_barcode:scan')).to.be.true; + expect(telemetryService.record.calledWith('search_by_barcode: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(searchFiltersService.freetextSearch.notCalled).to.be.true; + })); + + it('should catch exceptions', fakeAsync(async () => { + sessionService.isAdmin.returns(false); + authService.has.resolves(true); + 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'); + component.showBarcodeScanner = true; + sinon.resetHistory(); + + await component.ngAfterViewInit(); + + expect(getSupportedFormatsStub.calledOnce).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('search_by_barcode: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(searchFiltersService.freetextSearch.notCalled).to.be.true; + expect(feedbackService.submit.calledWith('some nice text')).to.be.true; + expect(telemetryService.record.calledWith('search_by_barcode:failure')).to.be.true; + })); + + it('should record telemetry when barcode is clicked.', fakeAsync(async () => { + sessionService.isAdmin.returns(false); + authService.has.resolves(true); + getSupportedFormatsStub.resolves([ 'code_39' ]); + 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/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; + }); }); From fdf2c2ee532a439e3cbd9a8c0ae08ecc19fc3fc6 Mon Sep 17 00:00:00 2001 From: latin-panda <66472237+latin-panda@users.noreply.github.com> Date: Mon, 14 Oct 2024 11:47:56 +0700 Subject: [PATCH 02/10] Enketo widget --- webapp/src/js/enketo/widgets.js | 1 + webapp/src/js/enketo/widgets/barcode-scanner.js | 0 2 files changed, 1 insertion(+) create mode 100644 webapp/src/js/enketo/widgets/barcode-scanner.js 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..e69de29bb2d From 624e0be652c76091a3c8bb0318dae4032fa8cfce Mon Sep 17 00:00:00 2001 From: latin-panda <66472237+latin-panda@users.noreply.github.com> Date: Mon, 14 Oct 2024 23:54:11 +0700 Subject: [PATCH 03/10] enketo widget --- .../translations/messages-bm.properties | 1 + .../translations/messages-en.properties | 1 + .../translations/messages-es.properties | 1 + .../translations/messages-fr.properties | 1 + .../translations/messages-hi.properties | 1 + .../translations/messages-id.properties | 1 + .../translations/messages-ne.properties | 1 + .../translations/messages-sw.properties | 1 + .../src/js/enketo/widgets/barcode-scanner.js | 52 +++++++++++++++++++ .../ts/services/barcode-scanner.service.ts | 8 +++ .../ts/services/integration-api.service.ts | 4 ++ 11 files changed, 72 insertions(+) create mode 100644 webapp/src/ts/services/barcode-scanner.service.ts diff --git a/api/resources/translations/messages-bm.properties b/api/resources/translations/messages-bm.properties index e1cb55a05ce..19f49889e64 100644 --- a/api/resources/translations/messages-bm.properties +++ b/api/resources/translations/messages-bm.properties @@ -375,6 +375,7 @@ associated.contact.help = Ni nin labaarabaga ye baara kɔmasegin kunafoniw da u autoreply = Jaabili yɔrɔbɛ barcode_scanner.error.cannot_read_barcode = Kɛrɛfɛ kɛmɛya la, i kɛmɛya. barcode_scanner.label.scan = kɛrɛfɛ +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 9e1e235d331..4e0e957f1e7 100644 --- a/api/resources/translations/messages-en.properties +++ b/api/resources/translations/messages-en.properties @@ -383,6 +383,7 @@ associated.contact.help = When this user creates reports they will be assigned t autoreply = autoreply barcode_scanner.error.cannot_read_barcode = Failed to read the barcode. Retry. barcode_scanner.label.scan = Scan +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 ac8f9566689..3b053677460 100644 --- a/api/resources/translations/messages-es.properties +++ b/api/resources/translations/messages-es.properties @@ -383,6 +383,7 @@ associated.contact.help = Cuando este usuario crea informes, serán asignados a autoreply = respuesta automática barcode_scanner.error.cannot_read_barcode = No se puede leer el código de barras. Vuelva a intentarlo. barcode_scanner.label.scan = Escanear +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 diff --git a/api/resources/translations/messages-fr.properties b/api/resources/translations/messages-fr.properties index dfe8b8be7a7..272946084b1 100644 --- a/api/resources/translations/messages-fr.properties +++ b/api/resources/translations/messages-fr.properties @@ -383,6 +383,7 @@ associated.contact.help = Lorsque cet utilisateur crée des rapport, ils seront autoreply = auto-réponse barcode_scanner.error.cannot_read_barcode = Impossible de lire le code barre. Veuillez réessayer. barcode_scanner.label.scan = Scanner +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 31860d78154..a0313193422 100644 --- a/api/resources/translations/messages-hi.properties +++ b/api/resources/translations/messages-hi.properties @@ -375,6 +375,7 @@ associated.contact.help = जब यह यूजर रिपोर्ट ब autoreply = स्वचालित जवाब barcode_scanner.error.cannot_read_barcode = बारकोड पढ़ने में विफल। पुनः प्रयास करें। barcode_scanner.label.scan = स्कैन करना +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 1325c174061..f496a2edae2 100644 --- a/api/resources/translations/messages-id.properties +++ b/api/resources/translations/messages-id.properties @@ -375,6 +375,7 @@ associated.contact.help = Ketika pengguna ini membuat laporan, mereka akan dihub autoreply = jawab otomatis barcode_scanner.error.cannot_read_barcode = Gagal membaca barcode. Mencoba kembali. barcode_scanner.label.scan = Memindai +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 19f4201bb50..e93888abefe 100644 --- a/api/resources/translations/messages-ne.properties +++ b/api/resources/translations/messages-ne.properties @@ -379,6 +379,7 @@ associated.contact.help = जब यो प्रयोगकर्ताले autoreply = स्वचालित जवाफ barcode_scanner.error.cannot_read_barcode = बारकोड पढ्न सकिएन। पुन: प्रयास गर्नुहोस्। barcode_scanner.label.scan = स्क्यान गर्नु +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 2146c0aa041..a120b556aac 100644 --- a/api/resources/translations/messages-sw.properties +++ b/api/resources/translations/messages-sw.properties @@ -385,6 +385,7 @@ associated.contact.help = Mhusika huyu akitoa ripoti, zitahusishwa na mwenzi huy autoreply = ujumbe wa moja kwa moja barcode_scanner.error.cannot_read_barcode = Imeshindwa kusoma msimbo pau. Jaribu tena. barcode_scanner.label.scan = Kuchanganua +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/webapp/src/js/enketo/widgets/barcode-scanner.js b/webapp/src/js/enketo/widgets/barcode-scanner.js index e69de29bb2d..9c25289b6ea 100644 --- a/webapp/src/js/enketo/widgets/barcode-scanner.js +++ b/webapp/src/js/enketo/widgets/barcode-scanner.js @@ -0,0 +1,52 @@ +'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; + } + + _init() { + const $widget = $(this.element); + + if (!window.CHTCore.BarcodeScanner.isEnabled()) { + window.CHTCore.Translate + .get('barcode_scanner.message.disable') + .then(label => $widget.append(``)); + return; + } + + window.CHTCore.Translate + .get('barcode_scanner.label.scan') + .then(label => { + $widget.append( + `` + ); + + $widget.on('click', '.btn.scan-barcode', () => this.scanBarcode($widget)); + }); + } + + scanBarcode() { + window.CHTCore.BarcodeScanner + .scanBarcode() + .then(code => { + $(APPEARANCES.input) + .val(code) + .trigger('change'); + }); + } +} + +module.exports = Barcodescannerwidget; 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..98bab985331 --- /dev/null +++ b/webapp/src/ts/services/barcode-scanner.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class BarcodeScannerService { + +} 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; From 69971c1dcc332bccbc787f5f2d9b4bf38cc80f9a Mon Sep 17 00:00:00 2001 From: latin-panda <66472237+latin-panda@users.noreply.github.com> Date: Wed, 16 Oct 2024 22:44:38 +0700 Subject: [PATCH 04/10] barcode service --- .../src/js/enketo/widgets/barcode-scanner.js | 41 ++++--- .../search-bar/search-bar.component.ts | 104 ++++-------------- .../ts/services/barcode-scanner.service.ts | 95 +++++++++++++++- 3 files changed, 134 insertions(+), 106 deletions(-) diff --git a/webapp/src/js/enketo/widgets/barcode-scanner.js b/webapp/src/js/enketo/widgets/barcode-scanner.js index 9c25289b6ea..40801f57f4b 100644 --- a/webapp/src/js/enketo/widgets/barcode-scanner.js +++ b/webapp/src/js/enketo/widgets/barcode-scanner.js @@ -17,35 +17,34 @@ class Barcodescannerwidget extends Widget { return APPEARANCES.widget; } - _init() { + async _init() { const $widget = $(this.element); - if (!window.CHTCore.BarcodeScanner.isEnabled()) { + const canScanBarcodes = await window.CHTCore.BarcodeScanner.canScanBarcodes(); + if (!canScanBarcodes) { window.CHTCore.Translate .get('barcode_scanner.message.disable') .then(label => $widget.append(``)); return; } - window.CHTCore.Translate - .get('barcode_scanner.label.scan') - .then(label => { - $widget.append( - `` - ); - - $widget.on('click', '.btn.scan-barcode', () => this.scanBarcode($widget)); - }); - } - - scanBarcode() { - window.CHTCore.BarcodeScanner - .scanBarcode() - .then(code => { - $(APPEARANCES.input) - .val(code) - .trigger('change'); - }); + const barcodeImageElement = await window.CHTCore.BarcodeScanner.initBarcodeScanner(codes => { + $(APPEARANCES.input) + .val(codes) + .trigger('change'); + }); + + $widget.append( + `` + ); + + $widget.on( + 'change', + '.barcode-scanner-file', + event => window.CHTCore.BarcodeScanner.processBarcodeFile(event.target, barcodeImageElement) + ); } } 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 da1067722f2..26f4099abf3 100644 --- a/webapp/src/ts/components/search-bar/search-bar.component.ts +++ b/webapp/src/ts/components/search-bar/search-bar.component.ts @@ -7,11 +7,9 @@ import { AfterViewInit, Output, ViewChild, - Inject, } from '@angular/core'; import { Store } from '@ngrx/store'; import { combineLatest, Subscription } from 'rxjs'; -import { DOCUMENT } from '@angular/common'; import { Selectors } from '@mm-selectors/index'; import { FreetextFilterComponent } from '@mm-components/filters/freetext-filter/freetext-filter.component'; @@ -19,11 +17,8 @@ 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 { 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 { FeedbackService } from '@mm-services/feedback.service'; +import { BarcodeScannerService } from '@mm-services/barcode-scanner.service'; export const CAN_USE_BARCODE_SCANNER = 'can_search_with_barcode_scanner'; @@ -43,12 +38,8 @@ export class SearchBarComponent implements AfterContentInit, AfterViewInit, OnDe @Output() search: EventEmitter = new EventEmitter(); private readonly TELEMETRY_PREFIX = 'search_by_barcode'; - private globalAction: GlobalActions; - private barcodeDetector; private filters; - private barcodeTypes; private barcodeImageElement; - windowRef; subscription: Subscription = new Subscription(); activeFilters: number = 0; openSearch = false; @@ -57,20 +48,14 @@ export class SearchBarComponent implements AfterContentInit, AfterViewInit, OnDe @ViewChild(FreetextFilterComponent) freetextFilter?: FreetextFilterComponent; constructor( - private store: Store, - private responsiveService: ResponsiveService, - private searchFiltersService: SearchFiltersService, - private authService: AuthService, - private sessionService: SessionService, - private translateService: TranslateService, - private telemetryService: TelemetryService, - private browserDetectorService: BrowserDetectorService, - private feedbackService: FeedbackService, - @Inject(DOCUMENT) private document: Document, - ) { - this.windowRef = this.document.defaultView; - this.globalAction = new GlobalActions(store); - } + 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(); @@ -79,7 +64,7 @@ export class SearchBarComponent implements AfterContentInit, AfterViewInit, OnDe async ngAfterViewInit() { this.isBarcodeScannerAvailable = await this.canShowBarcodeScanner(); this.searchFiltersService.init(this.freetextFilter); - await this.initBarcodeScanner(); + this.barcodeImageElement = await this.barcodeScannerService.initBarcodeScanner(codes => this.scanBarcode(codes)); } private subscribeToStore() { @@ -97,40 +82,21 @@ export class SearchBarComponent implements AfterContentInit, AfterViewInit, OnDe this.subscription.add(subscription); } - private async initBarcodeScanner() { - if (!this.isBarcodeScannerAvailable) { - return; - } - - console.info(`Supported barcode formats: ${this.barcodeTypes?.join(', ')}`); - this.barcodeDetector = new this.windowRef.BarcodeDetector({ formats: this.barcodeTypes }); - - this.barcodeImageElement = this.windowRef.document.createElement('img'); - this.barcodeImageElement?.addEventListener('load', () => this.scanBarcode(this.barcodeImageElement)); // NOSONAR - } - onBarcodeOpen() { this.telemetryService.record(`${this.TELEMETRY_PREFIX}:open`); } processBarcodeFile($event) { - const input = $event.target; - if (!input.files) { - return; - } - const reader = new FileReader(); - reader.addEventListener('load', event => this.barcodeImageElement.src = event?.target?.result); - reader.readAsDataURL(input.files[0]); - input.value = ''; + this.barcodeScannerService.processBarcodeFile($event.target, this.barcodeImageElement); } - clear() { + async clear() { if (this.disabled) { return; } this.freetextFilter?.clear(true); this.toggleMobileSearch(false); - this.initBarcodeScanner(); + this.barcodeImageElement = await this.barcodeScannerService.initBarcodeScanner(codes => this.scanBarcode(codes)); } toggleMobileSearch(forcedValue?) { @@ -168,46 +134,16 @@ export class SearchBarComponent implements AfterContentInit, AfterViewInit, OnDe return false; } - this.barcodeTypes = await this.windowRef.BarcodeDetector?.getSupportedFormats(); - - if ( - !('BarcodeDetector' in this.windowRef) - || !this.barcodeTypes?.length - || this.browserDetectorService.isDesktopUserAgent() // But we won't support it in desktop's browser. - ) { - const message = 'Barcode Detector API is not supported in this browser.'; - console.error(message); - this.feedbackService.submit(message); - this.telemetryService.record(`${this.TELEMETRY_PREFIX}:not_supported`); - return false; - } - - return true; + return await this.barcodeScannerService.canScanBarcodes(); } - private async scanBarcode(imageHolder) { - const errorMessageKey = 'barcode_scanner.error.cannot_read_barcode'; - this.telemetryService.record(`${this.TELEMETRY_PREFIX}:scan`); - - try { - const barcodes = await this.barcodeDetector.detect(imageHolder); - if (barcodes.length) { - this.searchFiltersService.freetextSearch(barcodes[0].rawValue); - this.telemetryService.record(`${this.TELEMETRY_PREFIX}:trigger_search`); - return; - } - - const message = this.translateService.instant(errorMessageKey); - this.globalAction.setSnackbarContent(message); - 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); - this.feedbackService.submit(message); - this.telemetryService.record(`${this.TELEMETRY_PREFIX}:failure`); + private async scanBarcode(barcodes) { + if (!barcodes.length) { + return; } + + this.searchFiltersService.freetextSearch(barcodes[0].rawValue); + await this.telemetryService.record(`${this.TELEMETRY_PREFIX}:trigger_search`); } ngOnDestroy() { diff --git a/webapp/src/ts/services/barcode-scanner.service.ts b/webapp/src/ts/services/barcode-scanner.service.ts index 98bab985331..f40515d6959 100644 --- a/webapp/src/ts/services/barcode-scanner.service.ts +++ b/webapp/src/ts/services/barcode-scanner.service.ts @@ -1,8 +1,101 @@ -import { Injectable } from '@angular/core'; +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 = ''; + } } From 80309c97e03922bdc6a457a0d448cb147fa20842 Mon Sep 17 00:00:00 2001 From: latin-panda <66472237+latin-panda@users.noreply.github.com> Date: Tue, 15 Oct 2024 23:49:13 +0700 Subject: [PATCH 05/10] widget --- webapp/src/js/enketo/widgets/barcode-scanner.js | 11 +++++++---- webapp/src/ts/services/barcode-scanner.service.ts | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/webapp/src/js/enketo/widgets/barcode-scanner.js b/webapp/src/js/enketo/widgets/barcode-scanner.js index 40801f57f4b..c4fb35af716 100644 --- a/webapp/src/js/enketo/widgets/barcode-scanner.js +++ b/webapp/src/js/enketo/widgets/barcode-scanner.js @@ -28,15 +28,18 @@ class Barcodescannerwidget extends Widget { return; } - const barcodeImageElement = await window.CHTCore.BarcodeScanner.initBarcodeScanner(codes => { - $(APPEARANCES.input) - .val(codes) + 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( `` ); diff --git a/webapp/src/ts/services/barcode-scanner.service.ts b/webapp/src/ts/services/barcode-scanner.service.ts index f40515d6959..3db549dce65 100644 --- a/webapp/src/ts/services/barcode-scanner.service.ts +++ b/webapp/src/ts/services/barcode-scanner.service.ts @@ -32,7 +32,7 @@ export class BarcodeScannerService { if ( !('BarcodeDetector' in this.windowRef) || !barcodeTypes?.length - || this.browserDetectorService.isDesktopUserAgent() // CHT won't support it in desktop's browser. + //|| 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); From a1e24a21355ba40a1e688170d8e1c8c1b6f22091 Mon Sep 17 00:00:00 2001 From: latin-panda <66472237+latin-panda@users.noreply.github.com> Date: Tue, 15 Oct 2024 23:56:36 +0700 Subject: [PATCH 06/10] reverting condition --- webapp/src/ts/services/barcode-scanner.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/ts/services/barcode-scanner.service.ts b/webapp/src/ts/services/barcode-scanner.service.ts index 3db549dce65..f40515d6959 100644 --- a/webapp/src/ts/services/barcode-scanner.service.ts +++ b/webapp/src/ts/services/barcode-scanner.service.ts @@ -32,7 +32,7 @@ export class BarcodeScannerService { if ( !('BarcodeDetector' in this.windowRef) || !barcodeTypes?.length - //|| this.browserDetectorService.isDesktopUserAgent() // CHT won't support it in desktop's browser. + || 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); From 1834b495de259ac37566677fa34d79e299bacbe8 Mon Sep 17 00:00:00 2001 From: latin-panda <66472237+latin-panda@users.noreply.github.com> Date: Wed, 16 Oct 2024 18:56:38 +0700 Subject: [PATCH 07/10] styles --- webapp/src/css/enketo/medic.less | 10 ++++++++++ webapp/src/ts/services/barcode-scanner.service.ts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/webapp/src/css/enketo/medic.less b/webapp/src/css/enketo/medic.less index d64dd58455c..97d03f5b0f5 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: 2.5rem; + line-height: 2.5rem; + text-align: center; + vertical-align: middle; + .barcode-scanner-file { + display: none; + } + } } @media (max-width: @media-mobile) { diff --git a/webapp/src/ts/services/barcode-scanner.service.ts b/webapp/src/ts/services/barcode-scanner.service.ts index f40515d6959..3db549dce65 100644 --- a/webapp/src/ts/services/barcode-scanner.service.ts +++ b/webapp/src/ts/services/barcode-scanner.service.ts @@ -32,7 +32,7 @@ export class BarcodeScannerService { if ( !('BarcodeDetector' in this.windowRef) || !barcodeTypes?.length - || this.browserDetectorService.isDesktopUserAgent() // CHT won't support it in desktop's browser. + //|| 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); From 921f615bebcd1b192d725b8b43ed000af90aebc2 Mon Sep 17 00:00:00 2001 From: latin-panda <66472237+latin-panda@users.noreply.github.com> Date: Wed, 16 Oct 2024 18:56:52 +0700 Subject: [PATCH 08/10] styles --- webapp/src/ts/services/barcode-scanner.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/ts/services/barcode-scanner.service.ts b/webapp/src/ts/services/barcode-scanner.service.ts index 3db549dce65..f40515d6959 100644 --- a/webapp/src/ts/services/barcode-scanner.service.ts +++ b/webapp/src/ts/services/barcode-scanner.service.ts @@ -32,7 +32,7 @@ export class BarcodeScannerService { if ( !('BarcodeDetector' in this.windowRef) || !barcodeTypes?.length - //|| this.browserDetectorService.isDesktopUserAgent() // CHT won't support it in desktop's browser. + || 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); From 781275bc7f24aad51f3e7224ac55e2cc2c96cd6d Mon Sep 17 00:00:00 2001 From: latin-panda <66472237+latin-panda@users.noreply.github.com> Date: Wed, 16 Oct 2024 21:21:14 +0700 Subject: [PATCH 09/10] unit tests --- .../search-bar/search-bar.component.spec.ts | 179 +++------------- .../services/barcode-scanner.service.spec.ts | 192 ++++++++++++++++++ 2 files changed, 222 insertions(+), 149 deletions(-) create mode 100644 webapp/tests/karma/ts/services/barcode-scanner.service.spec.ts 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 b6fce810e01..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,10 +1,10 @@ + import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; import sinon from 'sinon'; import { expect } from 'chai'; import { TranslateFakeLoader, TranslateLoader, TranslateModule } from '@ngx-translate/core'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; -import { DOCUMENT } from '@angular/common'; 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'; @@ -13,17 +13,8 @@ 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 { TranslateService } from '@mm-services/translate.service'; import { TelemetryService } from '@mm-services/telemetry.service'; -import { GlobalActions } from '@mm-actions/global'; -import { BrowserDetectorService } from '@mm-services/browser-detector.service'; -import { FeedbackService } from '@mm-services/feedback.service'; - -class BarcodeDetector { - constructor() {} - static getSupportedFormats() {} - detect() {} -} +import { BarcodeScannerService } from '@mm-services/barcode-scanner.service'; describe('Search Bar Component', () => { let component: SearchBarComponent; @@ -33,13 +24,8 @@ describe('Search Bar Component', () => { let searchFiltersService; let authService; let sessionService; - let translateService; let telemetryService; - let documentRef; - let getSupportedFormatsStub; - let detectStub; - let browserDetectorService; - let feedbackService; + let barcodeScannerService; beforeEach(async () => { const mockedSelectors = [ @@ -53,10 +39,12 @@ describe('Search Bar Component', () => { responsiveService = { isMobile: sinon.stub() }; authService = { has: sinon.stub() }; sessionService = { isAdmin: sinon.stub() }; - translateService = { instant: sinon.stub() }; telemetryService = { record: sinon.stub() }; - browserDetectorService = { isDesktopUserAgent: sinon.stub() }; - feedbackService = { submit: sinon.stub() }; + barcodeScannerService = { + initBarcodeScanner: sinon.stub(), + processBarcodeFile: sinon.stub(), + canScanBarcodes: sinon.stub(), + }; await TestBed .configureTestingModule({ @@ -74,10 +62,8 @@ describe('Search Bar Component', () => { { provide: SearchFiltersService, useValue: searchFiltersService }, { provide: AuthService, useValue: authService }, { provide: SessionService, useValue: sessionService }, - { provide: TranslateService, useValue: translateService }, { provide: TelemetryService, useValue: telemetryService }, - { provide: BrowserDetectorService, useValue: browserDetectorService }, - { provide: FeedbackService, useValue: feedbackService }, + { provide: BarcodeScannerService, useValue: barcodeScannerService }, ] }) .compileComponents(); @@ -85,15 +71,7 @@ describe('Search Bar Component', () => { fixture = TestBed.createComponent(SearchBarComponent); component = fixture.componentInstance; store = TestBed.inject(MockStore); - documentRef = TestBed.inject(DOCUMENT); fixture.detectChanges(); - - component.windowRef = { - ...component.windowRef, - BarcodeDetector - }; - getSupportedFormatsStub = sinon.stub(BarcodeDetector, 'getSupportedFormats').resolves([]); - detectStub = sinon.stub(BarcodeDetector.prototype, 'detect'); }); afterEach(() => sinon.restore()); @@ -207,8 +185,7 @@ describe('Search Bar Component', () => { it('should return true if BarcodeDetector is supported, user has permission and is not admin', async () => { sessionService.isAdmin.returns(false); authService.has.resolves(true); - browserDetectorService.isDesktopUserAgent.returns(false); - getSupportedFormatsStub.resolves([ 'code_39', 'aztec' ]); + barcodeScannerService.canScanBarcodes.resolves(true); component.showBarcodeScanner = true; sinon.resetHistory(); @@ -216,7 +193,7 @@ describe('Search Bar Component', () => { expect(component.isBarcodeScannerAvailable).to.be.true; expect(sessionService.isAdmin.calledOnce).to.be.true; - expect(browserDetectorService.isDesktopUserAgent.called).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 ]); }); @@ -224,50 +201,29 @@ describe('Search Bar Component', () => { it('should return false if barcode scanner is configured to not show', async () => { sessionService.isAdmin.returns(false); authService.has.resolves(true); - browserDetectorService.isDesktopUserAgent.returns(false); - getSupportedFormatsStub.resolves([ 'code_39', 'aztec' ]); + barcodeScannerService.canScanBarcodes.resolves(true); component.showBarcodeScanner = false; sinon.resetHistory(); await component.ngAfterViewInit(); expect(component.isBarcodeScannerAvailable).to.be.false; - expect(browserDetectorService.isDesktopUserAgent.notCalled).to.be.true; + 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 browser is desktop', async () => { - sessionService.isAdmin.returns(false); - authService.has.resolves(true); - browserDetectorService.isDesktopUserAgent.returns(true); - getSupportedFormatsStub.resolves([ 'code_39', 'aztec' ]); - component.showBarcodeScanner = true; - sinon.resetHistory(); - - await component.ngAfterViewInit(); - - expect(component.isBarcodeScannerAvailable).to.be.false; - expect(sessionService.isAdmin.calledOnce).to.be.true; - expect(browserDetectorService.isDesktopUserAgent.called).to.be.true; - expect(authService.has.calledOnce).to.be.true; - expect(authService.has.args[0]).to.have.members([ CAN_USE_BARCODE_SCANNER ]); - expect(feedbackService.submit.calledWith('Barcode Detector API is not supported in this browser.')).to.be.true; - expect(telemetryService.record.calledWith('search_by_barcode:not_supported')).to.be.true; - }); - it('should return false if user does not have permission', async () => { sessionService.isAdmin.returns(false); authService.has.resolves(false); - browserDetectorService.isDesktopUserAgent.returns(false); - getSupportedFormatsStub.resolves([ 'code_39', 'aztec' ]); + barcodeScannerService.canScanBarcodes.resolves(true); component.showBarcodeScanner = true; sinon.resetHistory(); await component.ngAfterViewInit(); expect(component.isBarcodeScannerAvailable).to.be.false; - expect(browserDetectorService.isDesktopUserAgent.called).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 ]); @@ -276,14 +232,13 @@ describe('Search Bar Component', () => { it('should return false if user is admin', async () => { sessionService.isAdmin.returns(true); authService.has.resolves(true); - browserDetectorService.isDesktopUserAgent.returns(false); - getSupportedFormatsStub.resolves([ 'code_39', 'aztec' ]); + barcodeScannerService.canScanBarcodes.resolves(true); component.showBarcodeScanner = true; sinon.resetHistory(); await component.ngAfterViewInit(); - expect(browserDetectorService.isDesktopUserAgent.called).to.be.false; + expect(barcodeScannerService.canScanBarcodes.notCalled).to.be.true; expect(component.isBarcodeScannerAvailable).to.be.false; expect(sessionService.isAdmin.calledOnce).to.be.true; }); @@ -291,34 +246,14 @@ describe('Search Bar Component', () => { it('should return false if BarcodeDetector is not supported', async () => { sessionService.isAdmin.returns(false); authService.has.resolves(true); - browserDetectorService.isDesktopUserAgent.returns(false); - getSupportedFormatsStub.resolves([ 'code_39', 'aztec' ]); + barcodeScannerService.canScanBarcodes.resolves(false); sinon.resetHistory(); component.showBarcodeScanner = true; - component.windowRef = {}; await component.ngAfterViewInit(); - expect(browserDetectorService.isDesktopUserAgent.called).to.be.false; + expect(barcodeScannerService.canScanBarcodes.calledOnce).to.be.true; expect(component.isBarcodeScannerAvailable).to.be.false; - expect(feedbackService.submit.calledWith('Barcode Detector API is not supported in this browser.')).to.be.true; - expect(telemetryService.record.calledWith('search_by_barcode:not_supported')).to.be.true; - }); - - it('should return false if browser does not support any type of barcode', async () => { - sessionService.isAdmin.returns(false); - authService.has.resolves(true); - browserDetectorService.isDesktopUserAgent.returns(false); - getSupportedFormatsStub.resolves([]); - component.showBarcodeScanner = true; - sinon.resetHistory(); - - await component.ngAfterViewInit(); - - expect(browserDetectorService.isDesktopUserAgent.called).to.be.false; - expect(component.isBarcodeScannerAvailable).to.be.false; - expect(feedbackService.submit.calledWith('Barcode Detector API is not supported in this browser.')).to.be.true; - expect(telemetryService.record.calledWith('search_by_barcode:not_supported')).to.be.true; }); }); @@ -326,97 +261,43 @@ describe('Search Bar Component', () => { it('should scan barcode and trigger search', fakeAsync(async () => { sessionService.isAdmin.returns(false); authService.has.resolves(true); - 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' }]); + barcodeScannerService.canScanBarcodes.resolves(true); + barcodeScannerService.initBarcodeScanner.resolves(); component.showBarcodeScanner = true; sinon.resetHistory(); await component.ngAfterViewInit(); - expect(getSupportedFormatsStub.calledOnce).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(); + const callback = barcodeScannerService.initBarcodeScanner.args[0][0]; + callback([{ rawValue: '1234' }]); flush(); - expect(telemetryService.record.calledWith('search_by_barcode:scan')).to.be.true; expect(telemetryService.record.calledWith('search_by_barcode:trigger_search')).to.be.true; - expect(detectStub.calledWith(imageHolder)).to.be.true; expect(searchFiltersService.freetextSearch.calledWith('1234')).to.be.true; })); - it('should advice to retry if barcode was not detected', fakeAsync(async () => { + it('should not trigger search if no barcodes', fakeAsync(async () => { sessionService.isAdmin.returns(false); authService.has.resolves(true); - 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([]); + barcodeScannerService.canScanBarcodes.resolves(true); + barcodeScannerService.initBarcodeScanner.resolves(); component.showBarcodeScanner = true; sinon.resetHistory(); await component.ngAfterViewInit(); - expect(getSupportedFormatsStub.calledOnce).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('search_by_barcode:scan')).to.be.true; - expect(telemetryService.record.calledWith('search_by_barcode: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(searchFiltersService.freetextSearch.notCalled).to.be.true; - })); - - it('should catch exceptions', fakeAsync(async () => { - sessionService.isAdmin.returns(false); - authService.has.resolves(true); - 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'); - component.showBarcodeScanner = true; - sinon.resetHistory(); - - await component.ngAfterViewInit(); - - expect(getSupportedFormatsStub.calledOnce).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(); + const callback = barcodeScannerService.initBarcodeScanner.args[0][0]; + callback([]); flush(); - expect(telemetryService.record.calledWith('search_by_barcode: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(telemetryService.record.notCalled).to.be.true; expect(searchFiltersService.freetextSearch.notCalled).to.be.true; - expect(feedbackService.submit.calledWith('some nice text')).to.be.true; - expect(telemetryService.record.calledWith('search_by_barcode:failure')).to.be.true; })); it('should record telemetry when barcode is clicked.', fakeAsync(async () => { sessionService.isAdmin.returns(false); authService.has.resolves(true); - getSupportedFormatsStub.resolves([ 'code_39' ]); + barcodeScannerService.canScanBarcodes.resolves(true); component.showBarcodeScanner = true; sinon.resetHistory(); 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; + })); + }); +}); From 74f61c4db52c108230932806ec3417f6164bf75d Mon Sep 17 00:00:00 2001 From: latin-panda <66472237+latin-panda@users.noreply.github.com> Date: Wed, 16 Oct 2024 21:33:38 +0700 Subject: [PATCH 10/10] Removed unused translations --- api/resources/translations/messages-bm.properties | 1 - api/resources/translations/messages-en.properties | 1 - api/resources/translations/messages-es.properties | 1 - api/resources/translations/messages-fr.properties | 1 - api/resources/translations/messages-hi.properties | 1 - api/resources/translations/messages-id.properties | 1 - api/resources/translations/messages-ne.properties | 1 - api/resources/translations/messages-sw.properties | 1 - 8 files changed, 8 deletions(-) diff --git a/api/resources/translations/messages-bm.properties b/api/resources/translations/messages-bm.properties index 19f49889e64..8888d32bec8 100644 --- a/api/resources/translations/messages-bm.properties +++ b/api/resources/translations/messages-bm.properties @@ -374,7 +374,6 @@ 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.label.scan = kɛrɛfɛ barcode_scanner.message.disable = Barcode scanner bɛ mɔgɔ la mɔgɔ kɔnɔ. birth_date = Wolo Don/Kalo/San branding = diff --git a/api/resources/translations/messages-en.properties b/api/resources/translations/messages-en.properties index 4e0e957f1e7..707575f24ce 100644 --- a/api/resources/translations/messages-en.properties +++ b/api/resources/translations/messages-en.properties @@ -382,7 +382,6 @@ 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.label.scan = Scan barcode_scanner.message.disable = The barcode scanner is not available on this device. birth_date = Birth date branding = Branding diff --git a/api/resources/translations/messages-es.properties b/api/resources/translations/messages-es.properties index 3b053677460..a382080399a 100644 --- a/api/resources/translations/messages-es.properties +++ b/api/resources/translations/messages-es.properties @@ -382,7 +382,6 @@ 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.label.scan = Escanear 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 diff --git a/api/resources/translations/messages-fr.properties b/api/resources/translations/messages-fr.properties index 272946084b1..e28464f82aa 100644 --- a/api/resources/translations/messages-fr.properties +++ b/api/resources/translations/messages-fr.properties @@ -382,7 +382,6 @@ 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.label.scan = Scanner barcode_scanner.message.disable = Le lecteur de code-barres n'est pas disponible sur cet appareil. birth_date = Date de naissance branding = Personnalisation diff --git a/api/resources/translations/messages-hi.properties b/api/resources/translations/messages-hi.properties index a0313193422..4aaaa0921b8 100644 --- a/api/resources/translations/messages-hi.properties +++ b/api/resources/translations/messages-hi.properties @@ -374,7 +374,6 @@ associated.contact = कॉंटेक्ट से जुड़ा हुआ associated.contact.help = जब यह यूजर रिपोर्ट बनाएगा तो वे is कॉन्टैक्ट के साथ जोड़ लिए जाएंगे | autoreply = स्वचालित जवाब barcode_scanner.error.cannot_read_barcode = बारकोड पढ़ने में विफल। पुनः प्रयास करें। -barcode_scanner.label.scan = स्कैन करना barcode_scanner.message.disable = बारकोड स्कैनर इस डिवाइस पर उपलब्ध नहीं है। birth_date = जन्म दिन branding = diff --git a/api/resources/translations/messages-id.properties b/api/resources/translations/messages-id.properties index f496a2edae2..4c3371696c2 100644 --- a/api/resources/translations/messages-id.properties +++ b/api/resources/translations/messages-id.properties @@ -374,7 +374,6 @@ 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.label.scan = Memindai barcode_scanner.message.disable = Pemindai barcode tidak tersedia pada perangkat ini. birth_date = Tanggal Lahir branding = diff --git a/api/resources/translations/messages-ne.properties b/api/resources/translations/messages-ne.properties index e93888abefe..55ecdcc8691 100644 --- a/api/resources/translations/messages-ne.properties +++ b/api/resources/translations/messages-ne.properties @@ -378,7 +378,6 @@ associated.contact = सम्बद्ध सम्पर्क associated.contact.help = जब यो प्रयोगकर्ताले रिपोर्ट निर्माण गर्छ, तिनिहरु यो सम्पर्कमा निर्दिष्ट हुनेछन् autoreply = स्वचालित जवाफ barcode_scanner.error.cannot_read_barcode = बारकोड पढ्न सकिएन। पुन: प्रयास गर्नुहोस्। -barcode_scanner.label.scan = स्क्यान गर्नु barcode_scanner.message.disable = बारकोड स्क्यानर यस उपकरणमा उपलब्ध छैन। birth_date = जन्म मिति branding = diff --git a/api/resources/translations/messages-sw.properties b/api/resources/translations/messages-sw.properties index a120b556aac..a503d3c6a0b 100644 --- a/api/resources/translations/messages-sw.properties +++ b/api/resources/translations/messages-sw.properties @@ -384,7 +384,6 @@ 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.label.scan = Kuchanganua barcode_scanner.message.disable = Kisomaji cha barcode haipatikani kwenye kifaa hiki. birth_date = Tarehe ya kuzaliwa branding = Chapa