From 7098e0ee255041510f29c2f6396155b2197a6507 Mon Sep 17 00:00:00 2001 From: Sinucid Date: Thu, 16 Nov 2023 16:00:49 +0300 Subject: [PATCH 1/8] feat(fulfillment): push and background sync permissions (#903) Added browser's permissions check and support for push notification API that is required by offline mode. - created compositions for picking and customer note pages - fulfillment app integrated with notification center - fixed word break behavior of notification component's text content with long words - `refresh data` button in FA profile menu was renamed to `sync data`, and the button became disabled in offline mode to prevent unnecessary requests errors - NetworkState service was added to platform offline package to observe browser's network state - `getList` method was added to picking list service to get picking list by id - `PickingListContextFallback` was added to provide fallback picking list context by `pickingListId` route param closes: [HRZ-90430](https://spryker.atlassian.net/browse/HRZ-90430) --- .../notification/src/notification.styles.ts | 4 + .../customer-note.component.spec.ts | 2 +- .../list-item/list-item.component.spec.ts | 4 +- .../mocks/src/mock-offline-data-plugin.ts | 2 +- .../mocks/src/mock-picking-list.providers.ts | 8 + .../mocks/src/mock-picking-list.service.ts | 6 +- libs/domain/picking/offline/data-plugin.ts | 20 +- .../picker-header.component.spec.ts | 4 +- .../picking/picker/picker.component.spec.ts | 8 +- .../service-worker/push.initializer.ts | 2 - .../services/picking-list-default.service.ts | 5 + .../src/services/picking-list.service.ts | 1 + libs/domain/picking/src/feature.ts | 2 + libs/domain/picking/src/index.ts | 1 + .../picking/src/mixins/picking-list.mixin.ts | 50 +-- .../picking/src/picking-list.context.ts | 16 + libs/domain/picking/src/routes.ts | 31 +- .../user-profile.component.spec.ts | 27 +- .../user-profile/user-profile.component.ts | 14 +- .../warehouse-assignment.component.spec.ts | 2 +- .../warehouse-assignment.component.ts | 2 +- libs/platform/offline/src/feature.ts | 5 + libs/platform/offline/src/index.ts | 1 + libs/platform/offline/src/services/index.ts | 2 + .../services/network-state-default.service.ts | 22 ++ .../src/services/network-state.service.ts | 15 + .../sync-scheduler-default.service.ts | 11 +- .../push-notification/web/web-push.spec.ts | 347 +++++++++--------- .../push-notification/web/web-push.ts | 61 ++- .../fulfillment/experience/experience-data.ts | 6 + .../experience/pages/customer-note-page.ts | 20 + .../fulfillment/experience/pages/index.ts | 6 +- .../experience/pages/login-page.ts | 1 + ...picking-lists.ts => picking-lists-page.ts} | 1 + .../experience/pages/picking-picker-page.ts | 20 + ...lection.ts => warehouse-selection-page.ts} | 1 + .../presets/fulfillment/experience/service.ts | 7 + 37 files changed, 462 insertions(+), 275 deletions(-) create mode 100644 libs/domain/picking/src/picking-list.context.ts create mode 100644 libs/platform/offline/src/services/index.ts create mode 100644 libs/platform/offline/src/services/network-state-default.service.ts create mode 100644 libs/platform/offline/src/services/network-state.service.ts create mode 100644 libs/template/presets/fulfillment/experience/pages/customer-note-page.ts rename libs/template/presets/fulfillment/experience/pages/{picking-lists.ts => picking-lists-page.ts} (94%) create mode 100644 libs/template/presets/fulfillment/experience/pages/picking-picker-page.ts rename libs/template/presets/fulfillment/experience/pages/{warehouse-selection.ts => warehouse-selection-page.ts} (94%) create mode 100644 libs/template/presets/fulfillment/experience/service.ts diff --git a/libs/base/ui/overlays/notification/src/notification.styles.ts b/libs/base/ui/overlays/notification/src/notification.styles.ts index cfa70eb81c..98358c114d 100644 --- a/libs/base/ui/overlays/notification/src/notification.styles.ts +++ b/libs/base/ui/overlays/notification/src/notification.styles.ts @@ -34,6 +34,10 @@ export const notificationStyles = css` box-shadow: var(--oryx-elevation-3) var(--oryx-color-elevation); } + slot { + word-break: break-word; + } + slot:not([name]) { display: block; grid-row: 1; diff --git a/libs/domain/picking/customer-note/customer-note.component.spec.ts b/libs/domain/picking/customer-note/customer-note.component.spec.ts index e7eff2cda6..b71c9dd21b 100644 --- a/libs/domain/picking/customer-note/customer-note.component.spec.ts +++ b/libs/domain/picking/customer-note/customer-note.component.spec.ts @@ -20,7 +20,7 @@ class MockRouterService implements Partial { } class MockPickingListService implements Partial { - get = vi.fn().mockReturnValue(of([mockPickingListData[0]])); + getList = vi.fn().mockReturnValue(of(mockPickingListData[0])); startPicking = vi.fn().mockReturnValue(of(mockPickingListData[0])); getUpcomingPickingListId = vi.fn().mockReturnValue(of(null)); } diff --git a/libs/domain/picking/list-item/list-item.component.spec.ts b/libs/domain/picking/list-item/list-item.component.spec.ts index 9fa042587b..a78ce3e1be 100644 --- a/libs/domain/picking/list-item/list-item.component.spec.ts +++ b/libs/domain/picking/list-item/list-item.component.spec.ts @@ -18,7 +18,7 @@ class MockRouterService implements Partial { } class MockPickingListService implements Partial { - get = vi.fn().mockReturnValue(of([mockPickingListData[0]])); + getList = vi.fn().mockReturnValue(of(mockPickingListData[0])); startPicking = vi.fn().mockReturnValue(of(mockPickingListData[0])); getUpcomingPickingListId = vi.fn().mockReturnValue(of(null)); } @@ -135,7 +135,7 @@ describe('PickingListItemComponent', () => { describe('when cart note is not provided', () => { beforeEach(async () => { - service.get = vi.fn().mockReturnValue(of([mockPickingListData[1]])); + service.getList = vi.fn().mockReturnValue(of(mockPickingListData[1])); service.startPicking = vi .fn() .mockReturnValue(of(mockPickingListData[1])); diff --git a/libs/domain/picking/mocks/src/mock-offline-data-plugin.ts b/libs/domain/picking/mocks/src/mock-offline-data-plugin.ts index 5afdad74db..89bba98c86 100644 --- a/libs/domain/picking/mocks/src/mock-offline-data-plugin.ts +++ b/libs/domain/picking/mocks/src/mock-offline-data-plugin.ts @@ -6,7 +6,7 @@ export class MockOfflineDataPlugin extends OfflineDataPlugin { //mock } - refreshData(): Observable { + syncData(): Observable { return of(undefined); } } diff --git a/libs/domain/picking/mocks/src/mock-picking-list.providers.ts b/libs/domain/picking/mocks/src/mock-picking-list.providers.ts index 8b364cafde..3d643e8ed8 100644 --- a/libs/domain/picking/mocks/src/mock-picking-list.providers.ts +++ b/libs/domain/picking/mocks/src/mock-picking-list.providers.ts @@ -1,4 +1,8 @@ import { Provider } from '@spryker-oryx/di'; +import { + NetworkStateDefaultService, + NetworkStateService, +} from '@spryker-oryx/offline'; import { PickingHeaderService } from '@spryker-oryx/picking'; import { PickingSyncActionHandlerService } from '@spryker-oryx/picking/offline'; import { PickingListService } from '@spryker-oryx/picking/services'; @@ -19,4 +23,8 @@ export const mockPickingListProviders: Provider[] = [ provide: PickingSyncActionHandlerService, useClass: MockPickingSyncActionHandlerService, }, + { + provide: NetworkStateService, + useClass: NetworkStateDefaultService, + }, ]; diff --git a/libs/domain/picking/mocks/src/mock-picking-list.service.ts b/libs/domain/picking/mocks/src/mock-picking-list.service.ts index 527ed7ff8f..39af5bbf58 100644 --- a/libs/domain/picking/mocks/src/mock-picking-list.service.ts +++ b/libs/domain/picking/mocks/src/mock-picking-list.service.ts @@ -6,7 +6,7 @@ import { PickingListService, SortableQualifier, } from '@spryker-oryx/picking/services'; -import { BehaviorSubject, Observable, of } from 'rxjs'; +import { BehaviorSubject, map, Observable, of } from 'rxjs'; import { mockPickingListData } from './mock-picking-list'; export class MockPickingListService implements Partial { @@ -42,6 +42,10 @@ export class MockPickingListService implements Partial { return of(filteredData); } + getList(id: string): Observable { + return this.get({ ids: [id] }).pipe(map((list) => list[0] ?? null)); + } + startPicking(pickingList: PickingList): Observable { return of(pickingList); } diff --git a/libs/domain/picking/offline/data-plugin.ts b/libs/domain/picking/offline/data-plugin.ts index 64500e12dc..dd107bfe15 100644 --- a/libs/domain/picking/offline/data-plugin.ts +++ b/libs/domain/picking/offline/data-plugin.ts @@ -60,18 +60,10 @@ export class OfflineDataPlugin implements AppPlugin { this.subscription?.unsubscribe(); } - refreshData(injector: Injector): Observable { + syncData(injector: Injector): Observable { this.refreshing$.next(true); - return this.clearDb(injector).pipe( - switchMap(() => this.populateDb(injector)), - tap({ - next: () => { - this.refreshing$.next(false); - }, - error: () => { - this.refreshing$.next(false); - }, - }) + return this.populateData(injector).pipe( + tap(() => this.refreshing$.next(false)) ); } @@ -133,4 +125,10 @@ export class OfflineDataPlugin implements AppPlugin { ) ); } + + protected populateData(injector: Injector): Observable { + return this.clearDb(injector).pipe( + switchMap(() => this.populateDb(injector)) + ); + } } diff --git a/libs/domain/picking/picker-header/picker-header.component.spec.ts b/libs/domain/picking/picker-header/picker-header.component.spec.ts index d87363c6ac..8d8687679d 100644 --- a/libs/domain/picking/picker-header/picker-header.component.spec.ts +++ b/libs/domain/picking/picker-header/picker-header.component.spec.ts @@ -13,7 +13,7 @@ import { PickingPickerHeaderComponent } from './picker-header.component'; import { pickingPickerHeaderComponent } from './picker-header.def'; class MockPickingListService implements Partial { - get = vi.fn().mockReturnValue(of([mockPickingListData[0]])); + getList = vi.fn().mockReturnValue(of(mockPickingListData[0])); getUpcomingPickingListId = vi.fn().mockReturnValue(of(null)); } @@ -155,7 +155,7 @@ describe('PickingPickerHeaderComponent', () => { describe('when picking list does not have customer note', () => { beforeEach(async () => { - service.get = vi.fn().mockReturnValue(of([mockPickingListData[1]])); + service.getList = vi.fn().mockReturnValue(of(mockPickingListData[1])); element = await fixture( html` { - get = vi.fn().mockReturnValue(of([mockPickingListData[0]])); + getList = vi.fn().mockReturnValue(of(mockPickingListData[0])); finishPicking = vi.fn().mockReturnValue(of(mockPickingListData[0])); getUpcomingPickingListId = vi.fn().mockReturnValue(of(null)); } @@ -125,7 +125,7 @@ describe('PickingPickerComponent', () => { describe('when there is no picking lists', () => { beforeEach(async () => { - service.get = vi.fn().mockReturnValue(of([])); + service.getList = vi.fn().mockReturnValue(of(null)); element = await fixture( html`` @@ -155,7 +155,7 @@ describe('PickingPickerComponent', () => { describe('when all items are already picked', () => { beforeEach(async () => { - service.get = vi.fn().mockReturnValue(of([mockPickingListData[1]])); + service.getList = vi.fn().mockReturnValue(of(mockPickingListData[1])); service.finishPicking = vi .fn() .mockReturnValue(of(mockPickingListData[1])); @@ -222,7 +222,7 @@ describe('PickingPickerComponent', () => { ], }; - service.get = vi.fn().mockReturnValue(of([partiallyPickedPickingList])); + service.getList = vi.fn().mockReturnValue(of(partiallyPickedPickingList)); service.finishPicking = vi .fn() .mockReturnValue(of(partiallyPickedPickingList)); diff --git a/libs/domain/picking/service-worker/push.initializer.ts b/libs/domain/picking/service-worker/push.initializer.ts index 5e8c5e7d63..4ae172844e 100644 --- a/libs/domain/picking/service-worker/push.initializer.ts +++ b/libs/domain/picking/service-worker/push.initializer.ts @@ -27,8 +27,6 @@ export class PushInitializer implements AppInitializer { const payload: PushSyncPayload = event.data.json(); - console.log('Push', this.syncSchedulerService); - event.waitUntil( firstValueFrom( this.syncSchedulerService.schedule({ diff --git a/libs/domain/picking/services/src/services/picking-list-default.service.ts b/libs/domain/picking/services/src/services/picking-list-default.service.ts index 51a1a35b17..16ad4a0f2e 100644 --- a/libs/domain/picking/services/src/services/picking-list-default.service.ts +++ b/libs/domain/picking/services/src/services/picking-list-default.service.ts @@ -2,6 +2,7 @@ import { inject } from '@spryker-oryx/di'; import { BehaviorSubject, catchError, + map, Observable, switchMap, tap, @@ -50,6 +51,10 @@ export class PickingListDefaultService implements PickingListService { ); } + getList(id: string): Observable { + return this.get({ ids: [id] }).pipe(map((list) => list[0] ?? null)); + } + startPicking(pickingList: PickingList): Observable { this.upcomingPickingListId$.next(pickingList.id); return this.adapter.startPicking(pickingList).pipe( diff --git a/libs/domain/picking/services/src/services/picking-list.service.ts b/libs/domain/picking/services/src/services/picking-list.service.ts index 98aa46a4d1..84693d2836 100644 --- a/libs/domain/picking/services/src/services/picking-list.service.ts +++ b/libs/domain/picking/services/src/services/picking-list.service.ts @@ -8,6 +8,7 @@ import { export interface PickingListService { get(qualifier: PickingListQualifier): Observable; + getList(id: string): Observable; startPicking(pickingList: PickingList): Observable; getUpcomingPickingListId(): Observable; updatePickingItems(pickingList: PickingList): Observable; diff --git a/libs/domain/picking/src/feature.ts b/libs/domain/picking/src/feature.ts index 7a1c3a77ab..73b22ebdfb 100644 --- a/libs/domain/picking/src/feature.ts +++ b/libs/domain/picking/src/feature.ts @@ -21,6 +21,7 @@ import { pickingWarehouseAssignmentComponent, } from './components'; import { PickingConfig, providePickingConfig } from './config.provider'; +import { PickingListContextFallback } from './picking-list.context'; import { defaultPickingRoutes } from './routes'; import { PickingHeaderDefaultService, PickingHeaderService } from './services'; @@ -73,6 +74,7 @@ export class PickingFeature extends PickingServicesFeature { provide: LocaleAdapter, useClass: DefaultLocaleAdapter, }, + PickingListContextFallback, ...super.getProviders(), ]; } diff --git a/libs/domain/picking/src/index.ts b/libs/domain/picking/src/index.ts index 04af5a1c5f..a13755a2a5 100644 --- a/libs/domain/picking/src/index.ts +++ b/libs/domain/picking/src/index.ts @@ -3,4 +3,5 @@ export * from './config.provider'; export * from './feature'; export * from './mixins'; export * from './models'; +export * from './picking-list.context'; export * from './services'; diff --git a/libs/domain/picking/src/mixins/picking-list.mixin.ts b/libs/domain/picking/src/mixins/picking-list.mixin.ts index f719abc11f..13889392d9 100644 --- a/libs/domain/picking/src/mixins/picking-list.mixin.ts +++ b/libs/domain/picking/src/mixins/picking-list.mixin.ts @@ -1,5 +1,6 @@ +import { ContextController } from '@spryker-oryx/core'; import { resolve } from '@spryker-oryx/di'; -import { PickingListComponentProperties } from '@spryker-oryx/picking'; +import { PickingListContext } from '@spryker-oryx/picking'; import { PickingList, PickingListService, @@ -7,54 +8,41 @@ import { import { Signal, Type, - isDefined, - observe, + computed, signal, signalAware, + signalProperty, } from '@spryker-oryx/utilities'; import { LitElement } from 'lit'; -import { property } from 'lit/decorators.js'; -import { - BehaviorSubject, - Observable, - distinctUntilChanged, - filter, - map, - switchMap, -} from 'rxjs'; +import { Observable, of } from 'rxjs'; -export declare class PickingListMixinInterface - implements PickingListComponentProperties -{ - protected pickingListService: PickingListService; +export declare class PickingListMixinInterface { pickingListId?: string; - protected pickingList$: Observable; + protected context: Observable; + protected pickingListService: PickingListService; protected $pickingList: Signal; protected $upcomingPickingListId: Signal; } -export const PickingListMixin = < - T extends Type ->( +export const PickingListMixin = >( superClass: T ): Type & T => { @signalAware() class PickingListMixinClass extends superClass { - protected pickingListService = resolve(PickingListService); + @signalProperty({ reflect: true }) pickingListId?: string; - @property() pickingListId?: string; - - @observe() - protected pickingListId$ = new BehaviorSubject(this.pickingListId); + protected pickingListService = resolve(PickingListService); - protected pickingList$ = this.pickingListId$.pipe( - distinctUntilChanged(), - filter(isDefined), - switchMap((id) => this.pickingListService.get({ ids: [id] })), - map((list) => list?.[0] ?? null) + protected contextController = new ContextController(this); + protected $context = signal( + this.contextController.get(PickingListContext.PickingListId) ); - protected $pickingList = signal(this.pickingList$); + protected $pickingList = computed(() => { + const id = this.pickingListId ?? this.$context(); + + return id ? this.pickingListService.getList(id) : of(null); + }); protected $upcomingPickingListId = signal( this.pickingListService.getUpcomingPickingListId() diff --git a/libs/domain/picking/src/picking-list.context.ts b/libs/domain/picking/src/picking-list.context.ts new file mode 100644 index 0000000000..5064fd6f42 --- /dev/null +++ b/libs/domain/picking/src/picking-list.context.ts @@ -0,0 +1,16 @@ +import { ContextFallback } from '@spryker-oryx/core'; +import { inject, Provider } from '@spryker-oryx/di'; +import { RouterService } from '@spryker-oryx/router'; +import { map } from 'rxjs'; + +export const enum PickingListContext { + PickingListId = 'pickingListId', +} + +export const PickingListContextFallback: Provider = { + provide: `${ContextFallback}${PickingListContext.PickingListId}`, + useFactory: () => + inject(RouterService) + .currentParams() + .pipe(map((params) => params?.pickingListId)), +}; diff --git a/libs/domain/picking/src/routes.ts b/libs/domain/picking/src/routes.ts index 706a2db28e..bf6bf0e9f0 100644 --- a/libs/domain/picking/src/routes.ts +++ b/libs/domain/picking/src/routes.ts @@ -8,30 +8,35 @@ export const defaultPickingRoutes: RouteConfig[] = [ { path: '/', render: () => - html``, + html``, }, { path: '/warehouse-selection', render: () => - html``, + html``, }, { - path: '/picking-list/picking/:id', - render: ({ id }) => - html` + html``, + >`, leave: (): Observable => resolve(PickingHeaderService).guardWithDialog(), }, { - path: '/customer-note-info/:id', - render: ({ id }) => html` - + html` - `, + >`, }, ]; diff --git a/libs/domain/picking/user-profile/user-profile.component.spec.ts b/libs/domain/picking/user-profile/user-profile.component.spec.ts index 0adcb3bccf..c629f011ac 100644 --- a/libs/domain/picking/user-profile/user-profile.component.spec.ts +++ b/libs/domain/picking/user-profile/user-profile.component.spec.ts @@ -2,6 +2,7 @@ import { fixture } from '@open-wc/testing-helpers'; import { AuthService } from '@spryker-oryx/auth'; import { App, AppRef, StorageService } from '@spryker-oryx/core'; import { createInjector, destroyInjector } from '@spryker-oryx/di'; +import { NetworkStateService } from '@spryker-oryx/offline'; import { SyncSchedulerService } from '@spryker-oryx/offline/sync'; import { RouterService } from '@spryker-oryx/router'; import { i18n, nextTick, useComponent } from '@spryker-oryx/utilities'; @@ -11,7 +12,7 @@ import { PickingUserProfileComponent } from './user-profile.component'; import { pickingUserProfileComponent } from './user-profile.def'; const mockOfflineDataPlugin = { - refreshData: vi.fn().mockReturnValue( + syncData: vi.fn().mockReturnValue( of(undefined).pipe( switchMap(async () => { await nextTick(2); @@ -42,6 +43,10 @@ class MockStorageService implements Partial { get = vi.fn().mockReturnValue(of(undefined)); } +class MockNetworkStateService implements Partial { + get = vi.fn().mockReturnValue(of(true)); +} + describe('PickingUserProfileComponent', () => { let element: PickingUserProfileComponent; let routerService: MockRouterService; @@ -76,6 +81,10 @@ describe('PickingUserProfileComponent', () => { provide: StorageService, useClass: MockStorageService, }, + { + provide: NetworkStateService, + useClass: MockNetworkStateService, + }, ], }); @@ -205,27 +214,27 @@ describe('PickingUserProfileComponent', () => { describe('when user is on the main page', () => { it('should render receive data button', () => { - expect(element).toContainElement('.receive-data'); + expect(element).toContainElement('.sync-data'); }); describe('and the receive data button is clicked', () => { beforeEach(() => { - element.renderRoot.querySelector('.receive-data')?.click(); + element.renderRoot.querySelector('.sync-data')?.click(); }); it('should call offline data plugin', () => { - expect(mockOfflineDataPlugin.refreshData).toHaveBeenCalled(); + expect(mockOfflineDataPlugin.syncData).toHaveBeenCalled(); }); it('should render loading indicator', async () => { - const button = element.renderRoot.querySelector('.receive-data'); - expect(button).toHaveProperty('text', 'Receive data'); + const button = element.renderRoot.querySelector('.sync-data'); + expect(button).toHaveProperty('text', 'Sync data'); expect(button?.hasAttribute('loading')).toBe(true); }); describe('and receive data completes', () => { beforeEach(async () => { - mockOfflineDataPlugin.refreshData.mockReturnValue(of(undefined)); + mockOfflineDataPlugin.syncData.mockReturnValue(of(undefined)); element = await fixture( `` @@ -236,8 +245,8 @@ describe('PickingUserProfileComponent', () => { }); it('should not show loading indicator', () => { - const button = element.renderRoot.querySelector('.receive-data'); - expect(button).toHaveProperty('text', 'Receive data'); + const button = element.renderRoot.querySelector('.sync-data'); + expect(button).toHaveProperty('text', 'Sync data'); expect(button?.hasAttribute('loading')).toBe(false); }); }); diff --git a/libs/domain/picking/user-profile/user-profile.component.ts b/libs/domain/picking/user-profile/user-profile.component.ts index 13d6edfa4e..8991533c9c 100644 --- a/libs/domain/picking/user-profile/user-profile.component.ts +++ b/libs/domain/picking/user-profile/user-profile.component.ts @@ -1,6 +1,7 @@ import { AuthService } from '@spryker-oryx/auth'; import { AppRef, StorageService } from '@spryker-oryx/core'; import { INJECTOR, resolve } from '@spryker-oryx/di'; +import { NetworkStateService } from '@spryker-oryx/offline'; import { SyncSchedulerService } from '@spryker-oryx/offline/sync'; import { OfflineDataPlugin } from '@spryker-oryx/picking/offline'; import { @@ -30,6 +31,7 @@ export class PickingUserProfileComponent extends I18nMixin(LitElement) { protected routerService = resolve(RouterService); protected authService = resolve(AuthService); protected storageService = resolve(StorageService); + protected networkStateService = resolve(NetworkStateService); protected injector = resolve(INJECTOR); protected injectorDataPlugin = @@ -55,6 +57,7 @@ export class PickingUserProfileComponent extends I18nMixin(LitElement) { "user.profile.you-can't-log-out-because-of-a-pending-synchronization" ) ); + protected $networkState = signal(this.networkStateService.get()); protected override render(): TemplateResult { return html` @@ -103,13 +106,14 @@ export class PickingUserProfileComponent extends I18nMixin(LitElement) { () => html` ` )} @@ -119,11 +123,11 @@ export class PickingUserProfileComponent extends I18nMixin(LitElement) { `; } - protected onReceiveData(): void { + protected onSyncData(): void { this.loading = true; this.injectorDataPlugin - .refreshData(this.injector) + .syncData(this.injector) .pipe(tap(() => (this.loading = false))) .subscribe(() => { this.dispatchEvent( diff --git a/libs/domain/picking/warehouse-assignment/warehouse-assignment.component.spec.ts b/libs/domain/picking/warehouse-assignment/warehouse-assignment.component.spec.ts index 030e1e9782..5743f3061c 100644 --- a/libs/domain/picking/warehouse-assignment/warehouse-assignment.component.spec.ts +++ b/libs/domain/picking/warehouse-assignment/warehouse-assignment.component.spec.ts @@ -13,7 +13,7 @@ import { beforeEach, vi } from 'vitest'; import { PickingWarehouseAssignmentComponent } from './warehouse-assignment.component'; const mockOfflineDataPlugin = { - refreshData: vi.fn().mockReturnValue( + syncData: vi.fn().mockReturnValue( of(undefined).pipe( switchMap(async () => { await nextTick(2); diff --git a/libs/domain/picking/warehouse-assignment/warehouse-assignment.component.ts b/libs/domain/picking/warehouse-assignment/warehouse-assignment.component.ts index 38d13e5568..d05b20f3f8 100644 --- a/libs/domain/picking/warehouse-assignment/warehouse-assignment.component.ts +++ b/libs/domain/picking/warehouse-assignment/warehouse-assignment.component.ts @@ -52,7 +52,7 @@ export class PickingWarehouseAssignmentComponent extends LitElement { .pipe( switchMap(() => { this.routerService.navigate('/'); - return this.injectorDataPlugin.refreshData(this.injector); + return this.injectorDataPlugin.syncData(this.injector); }) ) .subscribe(); diff --git a/libs/platform/offline/src/feature.ts b/libs/platform/offline/src/feature.ts index 297feb1239..fb5c4f2fb6 100644 --- a/libs/platform/offline/src/feature.ts +++ b/libs/platform/offline/src/feature.ts @@ -6,6 +6,7 @@ import { SyncSchedulerDefaultService, SyncSchedulerService, } from '@spryker-oryx/offline/sync'; +import { NetworkStateDefaultService, NetworkStateService } from './services'; export class OfflineFeature implements AppFeature { providers: Provider[] = this.getProviders(); @@ -17,6 +18,10 @@ export class OfflineFeature implements AppFeature { provide: SyncSchedulerService, useClass: SyncSchedulerDefaultService, }, + { + provide: NetworkStateService, + useClass: NetworkStateDefaultService, + }, ]; } } diff --git a/libs/platform/offline/src/index.ts b/libs/platform/offline/src/index.ts index 42a9f556aa..692104ac0b 100644 --- a/libs/platform/offline/src/index.ts +++ b/libs/platform/offline/src/index.ts @@ -1 +1,2 @@ export * from './feature'; +export * from './services'; diff --git a/libs/platform/offline/src/services/index.ts b/libs/platform/offline/src/services/index.ts new file mode 100644 index 0000000000..d50bcdaad4 --- /dev/null +++ b/libs/platform/offline/src/services/index.ts @@ -0,0 +1,2 @@ +export * from './network-state-default.service'; +export * from './network-state.service'; diff --git a/libs/platform/offline/src/services/network-state-default.service.ts b/libs/platform/offline/src/services/network-state-default.service.ts new file mode 100644 index 0000000000..2b7fd5eaec --- /dev/null +++ b/libs/platform/offline/src/services/network-state-default.service.ts @@ -0,0 +1,22 @@ +import { + Observable, + distinctUntilChanged, + fromEvent, + map, + merge, + of, +} from 'rxjs'; +import { NetworkState, NetworkStateService } from './network-state.service'; + +export class NetworkStateDefaultService implements NetworkStateService { + get(): Observable { + return merge( + of(null), + fromEvent(window, 'online'), + fromEvent(window, 'offline') + ).pipe( + map(() => (navigator.onLine ? 'online' : 'offline')), + distinctUntilChanged() + ); + } +} diff --git a/libs/platform/offline/src/services/network-state.service.ts b/libs/platform/offline/src/services/network-state.service.ts new file mode 100644 index 0000000000..04dd8dcc80 --- /dev/null +++ b/libs/platform/offline/src/services/network-state.service.ts @@ -0,0 +1,15 @@ +import { Observable } from 'rxjs'; + +export type NetworkState = 'online' | 'offline'; + +export interface NetworkStateService { + get(): Observable; +} + +export const NetworkStateService = 'oryx.NetworkStateService'; + +declare global { + export interface InjectionTokensContractMap { + [NetworkStateService]: NetworkStateService; + } +} diff --git a/libs/platform/offline/sync/services/sync-scheduler-default.service.ts b/libs/platform/offline/sync/services/sync-scheduler-default.service.ts index 8c43c87624..68f28145a7 100644 --- a/libs/platform/offline/sync/services/sync-scheduler-default.service.ts +++ b/libs/platform/offline/sync/services/sync-scheduler-default.service.ts @@ -183,6 +183,7 @@ export class SyncSchedulerDefaultService implements SyncSchedulerService { this.scheduleSyncTimer = setTimeout(async () => { const sync = (await this.getServiceWorker()).sync; + //when there is no registered service worker if (!sync) { throw new Error( `SyncSchedulerDefaultService: Unable to register background sync! @@ -190,7 +191,15 @@ export class SyncSchedulerDefaultService implements SyncSchedulerService { ); } - await sync.register(ProcessSyncsBackgroundSyncTag); + try { + await sync.register(ProcessSyncsBackgroundSyncTag); + } catch { + //when background sync is denied + throw new Error( + `The application does not have permissions to process data from and to the backend. + Please, provide your permission to enable background sync in the browser` + ); + } this.scheduleSyncTimer = undefined; }); diff --git a/libs/platform/push-notification/web/web-push.spec.ts b/libs/platform/push-notification/web/web-push.spec.ts index 666ef3a060..106b333c50 100644 --- a/libs/platform/push-notification/web/web-push.spec.ts +++ b/libs/platform/push-notification/web/web-push.spec.ts @@ -1,7 +1,4 @@ -import { createInjector, destroyInjector } from '@spryker-oryx/di'; -import { PushProvider } from '@spryker-oryx/push-notification'; -import { from, lastValueFrom, map, of } from 'rxjs'; -import { WebPushProvider } from './web-push'; +import { of } from 'rxjs'; class PushManagerMock implements Pick @@ -25,175 +22,179 @@ const mockServiceWorker = { const callback = vi.fn(); describe('WebPushProvider', () => { - async function getPushManager() { - return await lastValueFrom( - from(navigator.serviceWorker.ready).pipe( - map( - (serviceWorker) => - serviceWorker.pushManager as unknown as PushManagerMock - ) - ) - ); - } - - let provider: WebPushProvider; - - beforeEach(() => { - vi.stubGlobal('navigator', { serviceWorker: mockServiceWorker }); - - const testInjector = createInjector({ - providers: [ - { - provide: PushProvider, - useClass: WebPushProvider, - }, - ], - }); - - provider = testInjector.inject(PushProvider) as WebPushProvider; + it('TODO: re-write tests in following ticket', () => { + expect(true).toBe(true); }); - afterEach(() => { - vi.clearAllMocks(); - vi.unstubAllGlobals(); - destroyInjector(); - }); - - it('should be provided', () => { - expect(provider).toBeInstanceOf(WebPushProvider); - }); - - describe('init() method', () => { - it('should immediately resolve', () => { - const callback = vi.fn(); - - provider.init().subscribe(callback); - - expect(callback).toHaveBeenCalled(); - }); - }); - - describe('getSubscription() method', () => { - let pushManager: PushManagerMock; - beforeEach(async () => { - pushManager = await getPushManager(); - }); - - it('should create and return Promise of subscription data from PushManager', async () => { - const mockSubscription = { endpoint: 'mock-endpoint' }; - - pushManager.subscription.toJSON.mockReturnValue(mockSubscription); - pushManager.getSubscription.mockReturnValue(of(undefined)); - provider.getSubscription().subscribe(callback); - - expect(pushManager.subscribe).toHaveBeenCalledWith({ - userVisibleOnly: true, - }); - expect(callback).toHaveBeenCalledWith(mockSubscription); - // Should be a different object then original subscription - expect(callback).not.toHaveBeenCalledWith(pushManager.subscription); - }); - - describe('when using app server key', () => { - beforeEach(async () => { - destroyInjector(); - - const testInjector = createInjector({ - providers: [ - { - provide: PushProvider, - useFactory: () => - new WebPushProvider({ - applicationServerKey: 'mock-app&-server key==', - }), - }, - ], - }); - provider = testInjector.inject(PushProvider) as WebPushProvider; - pushManager = await getPushManager(); - }); - - it('should use app server key to create subscription if configured', async () => { - provider.getSubscription().subscribe(callback); - expect( - pushManager.subscribe, - 'applicationServerKey should be URL-safe without base64 padding' - ).toHaveBeenCalledWith({ - applicationServerKey: 'mock-app%26-server%20key', - userVisibleOnly: true, - }); - }); - }); - - describe('when userVisibleOnly flag is configured', () => { - beforeEach(async () => { - destroyInjector(); - - const testInjector = createInjector({ - providers: [ - { - provide: PushProvider, - useFactory: () => - new WebPushProvider({ - userVisibleOnly: false, - }), - }, - ], - }); - provider = testInjector.inject(PushProvider) as WebPushProvider; - pushManager = await getPushManager(); - }); - it('should use custom userVisibleOnly flag', async () => { - provider.getSubscription().subscribe(callback); - expect(pushManager.subscribe).toHaveBeenCalledWith({ - userVisibleOnly: false, - }); - }); - }); - - it('should return existing subscription data if subscribed before', async () => { - const pushSubscription = new PushSubscriptionMock(); - const mockSubscription = { endpoint: 'mock-endpoint' }; - - pushSubscription.toJSON.mockReturnValue(mockSubscription); - pushManager.getSubscription.mockReturnValue(of(pushSubscription)); - - provider.getSubscription().subscribe(callback); - expect(pushManager.subscribe).not.toHaveBeenCalled(); - expect(callback).toHaveBeenCalledWith(mockSubscription); - }); - }); - - describe('deleteSubscription() method', () => { - let pushManager: PushManagerMock; - const pushSubscription = new PushSubscriptionMock(); - describe('when subscription exists', () => { - beforeEach(async () => { - pushManager = await getPushManager(); - pushManager.getSubscription.mockReturnValue(of(pushSubscription)); - }); - it('should cancel existing subscription and return Promise of `true` if successful', () => { - pushSubscription.unsubscribe.mockReturnValue(of(true)); - - provider.deleteSubscription().subscribe(callback); - expect(callback).toHaveBeenCalledWith(true); - }); - - it('should cancel existing subscription and return Promise of `false` if unsuccessful', () => { - pushSubscription.unsubscribe.mockReturnValue(of(false)); - - provider.deleteSubscription().subscribe(callback); - expect(callback).toHaveBeenCalledWith(false); - }); - }); - - describe('when subscription does not exist', () => { - it('should return Promise of `true`', () => { - pushManager.getSubscription.mockReturnValue(of(null)); - - provider.deleteSubscription().subscribe(callback); - expect(callback).toHaveBeenCalledWith(true); - }); - }); - }); + // async function getPushManager() { + // return await lastValueFrom( + // from(navigator.serviceWorker.ready).pipe( + // map( + // (serviceWorker) => + // serviceWorker.pushManager as unknown as PushManagerMock + // ) + // ) + // ); + // } + + // let provider: WebPushProvider; + + // beforeEach(() => { + // vi.stubGlobal('navigator', { serviceWorker: mockServiceWorker }); + + // const testInjector = createInjector({ + // providers: [ + // { + // provide: PushProvider, + // useClass: WebPushProvider, + // }, + // ], + // }); + + // provider = testInjector.inject(PushProvider) as WebPushProvider; + // }); + + // afterEach(() => { + // vi.clearAllMocks(); + // vi.unstubAllGlobals(); + // destroyInjector(); + // }); + + // it('should be provided', () => { + // expect(provider).toBeInstanceOf(WebPushProvider); + // }); + + // describe('init() method', () => { + // it('should immediately resolve', () => { + // const callback = vi.fn(); + + // provider.init().subscribe(callback); + + // expect(callback).toHaveBeenCalled(); + // }); + // }); + + // describe('getSubscription() method', () => { + // let pushManager: PushManagerMock; + // beforeEach(async () => { + // pushManager = await getPushManager(); + // }); + + // it('should create and return Promise of subscription data from PushManager', async () => { + // const mockSubscription = { endpoint: 'mock-endpoint' }; + + // pushManager.subscription.toJSON.mockReturnValue(mockSubscription); + // pushManager.getSubscription.mockReturnValue(of(undefined)); + // provider.getSubscription().subscribe(callback); + + // expect(pushManager.subscribe).toHaveBeenCalledWith({ + // userVisibleOnly: true, + // }); + // expect(callback).toHaveBeenCalledWith(mockSubscription); + // // Should be a different object then original subscription + // expect(callback).not.toHaveBeenCalledWith(pushManager.subscription); + // }); + + // describe('when using app server key', () => { + // beforeEach(async () => { + // destroyInjector(); + + // const testInjector = createInjector({ + // providers: [ + // { + // provide: PushProvider, + // useFactory: () => + // new WebPushProvider({ + // applicationServerKey: 'mock-app&-server key==', + // }), + // }, + // ], + // }); + // provider = testInjector.inject(PushProvider) as WebPushProvider; + // pushManager = await getPushManager(); + // }); + + // it('should use app server key to create subscription if configured', async () => { + // provider.getSubscription().subscribe(callback); + // expect( + // pushManager.subscribe, + // 'applicationServerKey should be URL-safe without base64 padding' + // ).toHaveBeenCalledWith({ + // applicationServerKey: 'mock-app%26-server%20key', + // userVisibleOnly: true, + // }); + // }); + // }); + + // describe('when userVisibleOnly flag is configured', () => { + // beforeEach(async () => { + // destroyInjector(); + + // const testInjector = createInjector({ + // providers: [ + // { + // provide: PushProvider, + // useFactory: () => + // new WebPushProvider({ + // userVisibleOnly: false, + // }), + // }, + // ], + // }); + // provider = testInjector.inject(PushProvider) as WebPushProvider; + // pushManager = await getPushManager(); + // }); + // it('should use custom userVisibleOnly flag', async () => { + // provider.getSubscription().subscribe(callback); + // expect(pushManager.subscribe).toHaveBeenCalledWith({ + // userVisibleOnly: false, + // }); + // }); + // }); + + // it('should return existing subscription data if subscribed before', async () => { + // const pushSubscription = new PushSubscriptionMock(); + // const mockSubscription = { endpoint: 'mock-endpoint' }; + + // pushSubscription.toJSON.mockReturnValue(mockSubscription); + // pushManager.getSubscription.mockReturnValue(of(pushSubscription)); + + // provider.getSubscription().subscribe(callback); + // expect(pushManager.subscribe).not.toHaveBeenCalled(); + // expect(callback).toHaveBeenCalledWith(mockSubscription); + // }); + // }); + + // describe('deleteSubscription() method', () => { + // let pushManager: PushManagerMock; + // const pushSubscription = new PushSubscriptionMock(); + // describe('when subscription exists', () => { + // beforeEach(async () => { + // pushManager = await getPushManager(); + // pushManager.getSubscription.mockReturnValue(of(pushSubscription)); + // }); + // it('should cancel existing subscription and return Promise of `true` if successful', () => { + // pushSubscription.unsubscribe.mockReturnValue(of(true)); + + // provider.deleteSubscription().subscribe(callback); + // expect(callback).toHaveBeenCalledWith(true); + // }); + + // it('should cancel existing subscription and return Promise of `false` if unsuccessful', () => { + // pushSubscription.unsubscribe.mockReturnValue(of(false)); + + // provider.deleteSubscription().subscribe(callback); + // expect(callback).toHaveBeenCalledWith(false); + // }); + // }); + + // describe('when subscription does not exist', () => { + // it('should return Promise of `true`', () => { + // pushManager.getSubscription.mockReturnValue(of(null)); + + // provider.deleteSubscription().subscribe(callback); + // expect(callback).toHaveBeenCalledWith(true); + // }); + // }); + // }); }); diff --git a/libs/platform/push-notification/web/web-push.ts b/libs/platform/push-notification/web/web-push.ts index 4458e49504..d4585fb8dc 100644 --- a/libs/platform/push-notification/web/web-push.ts +++ b/libs/platform/push-notification/web/web-push.ts @@ -29,12 +29,43 @@ export class WebPushProvider implements PushProvider { return of(undefined); } + protected async checkSupportAndPermission(): Promise { + let error = ''; + + if (!('serviceWorker' in navigator)) { + error = 'Browser does not support service-worker API'; + } else if (!('SyncManager' in window)) { + error = 'Browser does not support background sync API'; + } else if ( + (await navigator.permissions.query({ name: 'notifications' })).state === + 'denied' + ) { + error = + 'Permission to accept push notifications is not granted. Check the browser configuration or reset the permission'; + } else if ( + ( + await navigator.permissions.query({ + name: 'background-sync' as PermissionName, + }) + ).state === 'denied' + ) { + error = + 'Permission to perform background sync is not granted. Check the browser configuration or reset the permission'; + } + + if (error) throw new Error(error); + } + getSubscription(): Observable { - return this.getExistingSubscription().pipe( - switchMap((subscription) => - subscription ? of(subscription) : this.createSubscription() - ), - map((subscription) => subscription.toJSON()) + return from(this.checkSupportAndPermission()).pipe( + switchMap(() => + this.getExistingSubscription().pipe( + switchMap((subscription) => + subscription ? of(subscription) : this.createSubscription() + ), + map((subscription) => subscription.toJSON()) + ) + ) ); } @@ -45,17 +76,8 @@ export class WebPushProvider implements PushProvider { } protected createSubscription(): Observable { - const userVisibleOnly = this.options?.userVisibleOnly ?? true; - const applicationServerKey = this.options?.applicationServerKey - ? this.encodeKey(this.options.applicationServerKey) - : undefined; return this.pushManager$.pipe( - switchMap((pushManager) => - pushManager.subscribe({ - applicationServerKey, - userVisibleOnly, - }) - ) + switchMap((pushManager) => pushManager.subscribe(this.getOptions())) ); } @@ -65,6 +87,15 @@ export class WebPushProvider implements PushProvider { ); } + protected getOptions(): Partial { + return { + userVisibleOnly: this.options?.userVisibleOnly ?? true, + applicationServerKey: this.options?.applicationServerKey + ? this.encodeKey(this.options.applicationServerKey) + : undefined, + }; + } + /** * The key should be URL-safe base64 encoded without padding (no trailing =) * @see https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#parameters diff --git a/libs/template/presets/fulfillment/experience/experience-data.ts b/libs/template/presets/fulfillment/experience/experience-data.ts index dcb1e2d4f5..710ed8d534 100644 --- a/libs/template/presets/fulfillment/experience/experience-data.ts +++ b/libs/template/presets/fulfillment/experience/experience-data.ts @@ -1,10 +1,13 @@ import { AppFeature } from '@spryker-oryx/core'; import { provideExperienceData } from '@spryker-oryx/experience'; import { + customerNotePage, fulfillmentLoginPage, pickingListsPage, + pickingPickerPage, warehouseSelectionPage, } from './pages'; +import { ServiceTemplate } from './service'; import { UserProfileComponent } from './user-profile'; export const StaticExperienceFeature: AppFeature = { @@ -14,6 +17,9 @@ export const StaticExperienceFeature: AppFeature = { fulfillmentLoginPage, warehouseSelectionPage, pickingListsPage, + customerNotePage, + pickingPickerPage, + ServiceTemplate, ]), ], }; diff --git a/libs/template/presets/fulfillment/experience/pages/customer-note-page.ts b/libs/template/presets/fulfillment/experience/pages/customer-note-page.ts new file mode 100644 index 0000000000..a984f613e6 --- /dev/null +++ b/libs/template/presets/fulfillment/experience/pages/customer-note-page.ts @@ -0,0 +1,20 @@ +import { ExperienceComponent } from '@spryker-oryx/experience'; + +export const customerNotePage: ExperienceComponent = { + id: 'customer-note', + type: 'Page', + meta: { + title: 'Customer Note', + route: '/customer-note-info/:pickingListId', + description: 'Customer Note Page Description', + }, + options: { + rules: [{ layout: 'list' }], + }, + components: [ + { + type: 'oryx-picking-customer-note', + }, + { ref: 'service' }, + ], +}; diff --git a/libs/template/presets/fulfillment/experience/pages/index.ts b/libs/template/presets/fulfillment/experience/pages/index.ts index 57d34b0f82..15b512591a 100644 --- a/libs/template/presets/fulfillment/experience/pages/index.ts +++ b/libs/template/presets/fulfillment/experience/pages/index.ts @@ -1,3 +1,5 @@ +export * from './customer-note-page'; export * from './login-page'; -export * from './picking-lists'; -export * from './warehouse-selection'; +export * from './picking-lists-page'; +export * from './picking-picker-page'; +export * from './warehouse-selection-page'; diff --git a/libs/template/presets/fulfillment/experience/pages/login-page.ts b/libs/template/presets/fulfillment/experience/pages/login-page.ts index 069c05dbe2..9d53c37428 100644 --- a/libs/template/presets/fulfillment/experience/pages/login-page.ts +++ b/libs/template/presets/fulfillment/experience/pages/login-page.ts @@ -38,5 +38,6 @@ export const fulfillmentLoginPage: ExperienceComponent = { type: 'oryx-auth-login', options: { enableRememberMe: false }, }, + { ref: 'service' }, ], }; diff --git a/libs/template/presets/fulfillment/experience/pages/picking-lists.ts b/libs/template/presets/fulfillment/experience/pages/picking-lists-page.ts similarity index 94% rename from libs/template/presets/fulfillment/experience/pages/picking-lists.ts rename to libs/template/presets/fulfillment/experience/pages/picking-lists-page.ts index 7b632d6f35..11aa57d655 100644 --- a/libs/template/presets/fulfillment/experience/pages/picking-lists.ts +++ b/libs/template/presets/fulfillment/experience/pages/picking-lists-page.ts @@ -15,5 +15,6 @@ export const pickingListsPage: ExperienceComponent = { { type: 'oryx-picking-lists', }, + { ref: 'service' }, ], }; diff --git a/libs/template/presets/fulfillment/experience/pages/picking-picker-page.ts b/libs/template/presets/fulfillment/experience/pages/picking-picker-page.ts new file mode 100644 index 0000000000..5ddae67abc --- /dev/null +++ b/libs/template/presets/fulfillment/experience/pages/picking-picker-page.ts @@ -0,0 +1,20 @@ +import { ExperienceComponent } from '@spryker-oryx/experience'; + +export const pickingPickerPage: ExperienceComponent = { + id: 'picking-picker', + type: 'Page', + meta: { + title: 'Picking Picker', + route: '/picking-list/picking/:pickingListId', + description: 'Picking Picker Page Description', + }, + options: { + rules: [{ layout: 'list' }], + }, + components: [ + { + type: 'oryx-picking-picker', + }, + { ref: 'service' }, + ], +}; diff --git a/libs/template/presets/fulfillment/experience/pages/warehouse-selection.ts b/libs/template/presets/fulfillment/experience/pages/warehouse-selection-page.ts similarity index 94% rename from libs/template/presets/fulfillment/experience/pages/warehouse-selection.ts rename to libs/template/presets/fulfillment/experience/pages/warehouse-selection-page.ts index 7910f681a4..97522b7cdc 100644 --- a/libs/template/presets/fulfillment/experience/pages/warehouse-selection.ts +++ b/libs/template/presets/fulfillment/experience/pages/warehouse-selection-page.ts @@ -15,5 +15,6 @@ export const warehouseSelectionPage: ExperienceComponent = { { type: 'oryx-picking-warehouse-assignment', }, + { ref: 'service' }, ], }; diff --git a/libs/template/presets/fulfillment/experience/service.ts b/libs/template/presets/fulfillment/experience/service.ts new file mode 100644 index 0000000000..f8c72abc97 --- /dev/null +++ b/libs/template/presets/fulfillment/experience/service.ts @@ -0,0 +1,7 @@ +import { ExperienceComponent } from '@spryker-oryx/experience'; + +export const ServiceTemplate: ExperienceComponent = { + type: 'oryx-composition', + id: 'service', + components: [{ type: 'oryx-site-notification-center' }], +}; From 067f7702564e3badac84e9b9b55cb318cb95a2e8 Mon Sep 17 00:00:00 2001 From: Sinucid Date: Fri, 17 Nov 2023 15:01:02 +0300 Subject: [PATCH 2/8] test(fulfillment): push permission unit tests (#909) closes: HRZ-90484 --- .github/workflows/test.yml | 3 +- .../user-profile.component.spec.ts | 58 ++- .../user-profile/user-profile.component.ts | 4 +- .../network-state-default.service.spec.ts | 69 +++ .../services/network-state-default.service.ts | 6 +- .../src/services/network-state.service.ts | 4 +- .../push-notification/web/web-push.spec.ts | 437 +++++++++++------- .../push-notification/web/web-push.ts | 62 +-- 8 files changed, 410 insertions(+), 233 deletions(-) create mode 100644 libs/platform/offline/src/services/network-state-default.service.spec.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 92b0351dc8..a327e2f24f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -147,7 +147,7 @@ jobs: SCOS_BASE_URL: ${{ secrets.SCOS_BASE_URL }} CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} STORE: DE - + ORYX_FEATURE_VERSION: latest with: cmd: npm run sf:e2e:headless:ci affectedCmd: npm run sf:e2e:headless:ci:affected @@ -168,6 +168,7 @@ jobs: SCOS_BASE_URL: ${{ secrets.SCOS_BASE_URL }} CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} STORE: DE + ORYX_FEATURE_VERSION: latest with: cmd: npm run sf:b2b:e2e:headless:ci affectedCmd: npm run sf:b2b:e2e:headless:ci:affected diff --git a/libs/domain/picking/user-profile/user-profile.component.spec.ts b/libs/domain/picking/user-profile/user-profile.component.spec.ts index c629f011ac..ec504c3649 100644 --- a/libs/domain/picking/user-profile/user-profile.component.spec.ts +++ b/libs/domain/picking/user-profile/user-profile.component.spec.ts @@ -31,7 +31,7 @@ class MockSyncSchedulerService implements Partial { } class MockRouterService implements Partial { - route = vi.fn().mockReturnValue(of('/')); + route = vi.fn().mockReturnValue(of('')); navigate = vi.fn(); } @@ -44,7 +44,7 @@ class MockStorageService implements Partial { } class MockNetworkStateService implements Partial { - get = vi.fn().mockReturnValue(of(true)); + online = vi.fn().mockReturnValue(of(true)); } describe('PickingUserProfileComponent', () => { @@ -53,6 +53,7 @@ describe('PickingUserProfileComponent', () => { let syncSchedulerService: MockSyncSchedulerService; let authService: MockAuthService; let storageService: MockStorageService; + let networkService: MockNetworkStateService; beforeAll(async () => { await useComponent(pickingUserProfileComponent); @@ -88,18 +89,13 @@ describe('PickingUserProfileComponent', () => { ], }); - routerService = testInjector.inject( - RouterService - ) as unknown as MockRouterService; - syncSchedulerService = testInjector.inject( - SyncSchedulerService - ) as unknown as MockSyncSchedulerService; - authService = testInjector.inject( - AuthService - ) as unknown as MockAuthService; - storageService = testInjector.inject( - StorageService - ) as unknown as MockStorageService; + routerService = testInjector.inject(RouterService); + syncSchedulerService = + testInjector.inject(SyncSchedulerService); + authService = testInjector.inject(AuthService); + storageService = testInjector.inject(StorageService); + networkService = + testInjector.inject(NetworkStateService); element = await fixture( html`` @@ -139,7 +135,7 @@ describe('PickingUserProfileComponent', () => { syncSchedulerService.hasPending.mockReturnValue(of(true)); element = await fixture( - '' + html`` ); }); @@ -213,8 +209,16 @@ describe('PickingUserProfileComponent', () => { }); describe('when user is on the main page', () => { - it('should render receive data button', () => { - expect(element).toContainElement('.sync-data'); + beforeEach(async () => { + routerService.route = vi.fn().mockReturnValue(of('/')); + + element = await fixture( + html`` + ); + }); + + it('should render not disabled sync data button', () => { + expect(element).toContainElement('.sync-data:not([disabled])'); }); describe('and the receive data button is clicked', () => { @@ -237,7 +241,7 @@ describe('PickingUserProfileComponent', () => { mockOfflineDataPlugin.syncData.mockReturnValue(of(undefined)); element = await fixture( - `` + html`` ); element.renderRoot .querySelector('oryx-button.received-data') @@ -251,6 +255,20 @@ describe('PickingUserProfileComponent', () => { }); }); }); + + describe('and network is in offline state', () => { + beforeEach(async () => { + networkService.online = vi.fn().mockReturnValue(of(false)); + + element = await fixture( + html`` + ); + }); + + it('should disable sync data button', () => { + expect(element).toContainElement('.sync-data[disabled]'); + }); + }); }); describe('when picking is in progress', () => { @@ -258,7 +276,7 @@ describe('PickingUserProfileComponent', () => { routerService.route.mockReturnValue('/picking/'); element = await fixture( - `` + html`` ); }); @@ -287,7 +305,7 @@ describe('PickingUserProfileComponent', () => { .mockReturnValue(of({ warehouse: { name: mockWarehouseName } })); element = await fixture( - `` + html`` ); }); diff --git a/libs/domain/picking/user-profile/user-profile.component.ts b/libs/domain/picking/user-profile/user-profile.component.ts index 8991533c9c..145d6d3d0b 100644 --- a/libs/domain/picking/user-profile/user-profile.component.ts +++ b/libs/domain/picking/user-profile/user-profile.component.ts @@ -57,7 +57,7 @@ export class PickingUserProfileComponent extends I18nMixin(LitElement) { "user.profile.you-can't-log-out-because-of-a-pending-synchronization" ) ); - protected $networkState = signal(this.networkStateService.get()); + protected $isOnline = signal(this.networkStateService.online()); protected override render(): TemplateResult { return html` @@ -112,7 +112,7 @@ export class PickingUserProfileComponent extends I18nMixin(LitElement) { .text=${this.i18n('user.profile.sync-data')} block ?loading=${this.loading} - ?disabled=${this.$networkState() === 'offline'} + ?disabled=${!this.$isOnline()} @click=${this.onSyncData} > ` diff --git a/libs/platform/offline/src/services/network-state-default.service.spec.ts b/libs/platform/offline/src/services/network-state-default.service.spec.ts new file mode 100644 index 0000000000..c37420a066 --- /dev/null +++ b/libs/platform/offline/src/services/network-state-default.service.spec.ts @@ -0,0 +1,69 @@ +import { createInjector, destroyInjector } from '@spryker-oryx/di'; +import { take } from 'rxjs'; +import { NetworkStateDefaultService } from './network-state-default.service'; +import { NetworkStateService } from './network-state.service'; + +describe('NetworkStateDefaultService', () => { + let service: NetworkStateService; + + beforeAll(() => { + vi.spyOn(navigator, 'onLine', 'get').mockReturnValueOnce(true); + + const testInjector = createInjector({ + providers: [ + { + provide: NetworkStateService, + useClass: NetworkStateDefaultService, + }, + ], + }); + + service = testInjector.inject(NetworkStateService); + }); + + afterEach(() => { + vi.clearAllMocks(); + destroyInjector(); + }); + + it('should return default online state', () => { + service + .online() + .pipe(take(1)) + .subscribe((online) => { + expect(online).toBe(true); + }); + }); + + describe('and network has gone offline', () => { + beforeEach(() => { + vi.spyOn(navigator, 'onLine', 'get').mockReturnValueOnce(false); + window.dispatchEvent(new Event('offline')); + }); + + it('should trigger the state', () => { + service + .online() + .pipe(take(1)) + .subscribe((online) => { + expect(online).toBe(false); + }); + }); + + describe('and connection was restored', () => { + beforeEach(() => { + vi.spyOn(navigator, 'onLine', 'get').mockReturnValueOnce(true); + window.dispatchEvent(new Event('online')); + }); + + it('should trigger the state', () => { + service + .online() + .pipe(take(1)) + .subscribe((online) => { + expect(online).toBe(true); + }); + }); + }); + }); +}); diff --git a/libs/platform/offline/src/services/network-state-default.service.ts b/libs/platform/offline/src/services/network-state-default.service.ts index 2b7fd5eaec..9d7054e29b 100644 --- a/libs/platform/offline/src/services/network-state-default.service.ts +++ b/libs/platform/offline/src/services/network-state-default.service.ts @@ -6,16 +6,16 @@ import { merge, of, } from 'rxjs'; -import { NetworkState, NetworkStateService } from './network-state.service'; +import { NetworkStateService } from './network-state.service'; export class NetworkStateDefaultService implements NetworkStateService { - get(): Observable { + online(): Observable { return merge( of(null), fromEvent(window, 'online'), fromEvent(window, 'offline') ).pipe( - map(() => (navigator.onLine ? 'online' : 'offline')), + map(() => navigator.onLine), distinctUntilChanged() ); } diff --git a/libs/platform/offline/src/services/network-state.service.ts b/libs/platform/offline/src/services/network-state.service.ts index 04dd8dcc80..83f8c728fa 100644 --- a/libs/platform/offline/src/services/network-state.service.ts +++ b/libs/platform/offline/src/services/network-state.service.ts @@ -1,9 +1,7 @@ import { Observable } from 'rxjs'; -export type NetworkState = 'online' | 'offline'; - export interface NetworkStateService { - get(): Observable; + online(): Observable; } export const NetworkStateService = 'oryx.NetworkStateService'; diff --git a/libs/platform/push-notification/web/web-push.spec.ts b/libs/platform/push-notification/web/web-push.spec.ts index 106b333c50..78a78a048a 100644 --- a/libs/platform/push-notification/web/web-push.spec.ts +++ b/libs/platform/push-notification/web/web-push.spec.ts @@ -1,4 +1,7 @@ -import { of } from 'rxjs'; +import { createInjector, destroyInjector } from '@spryker-oryx/di'; +import { PushProvider } from '@spryker-oryx/push-notification'; +import { from, lastValueFrom, map, of } from 'rxjs'; +import { WebPushProvider } from './web-push'; class PushManagerMock implements Pick @@ -19,182 +22,266 @@ const mockServiceWorker = { ready: of({ pushManager: new PushManagerMock() }), }; -const callback = vi.fn(); +const defaultPermissions = { + notification: 'granted', + 'background-sync': 'granted', +} as Record; + +const mockPermissions = (permissions = defaultPermissions) => ({ + query: ({ name }: { name: string }) => ({ state: permissions[name] }), +}); describe('WebPushProvider', () => { - it('TODO: re-write tests in following ticket', () => { - expect(true).toBe(true); + async function getPushManager() { + return await lastValueFrom( + from(navigator.serviceWorker.ready).pipe( + map( + (serviceWorker) => + serviceWorker.pushManager as unknown as PushManagerMock + ) + ) + ); + } + + let provider: WebPushProvider; + + beforeEach(() => { + vi.stubGlobal('navigator', { + serviceWorker: mockServiceWorker, + permissions: mockPermissions(), + }); + vi.stubGlobal('window', { SyncManager: {} }); + + const testInjector = createInjector({ + providers: [ + { + provide: PushProvider, + useClass: WebPushProvider, + }, + ], + }); + + provider = testInjector.inject(PushProvider) as WebPushProvider; + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + destroyInjector(); }); - // async function getPushManager() { - // return await lastValueFrom( - // from(navigator.serviceWorker.ready).pipe( - // map( - // (serviceWorker) => - // serviceWorker.pushManager as unknown as PushManagerMock - // ) - // ) - // ); - // } - - // let provider: WebPushProvider; - - // beforeEach(() => { - // vi.stubGlobal('navigator', { serviceWorker: mockServiceWorker }); - - // const testInjector = createInjector({ - // providers: [ - // { - // provide: PushProvider, - // useClass: WebPushProvider, - // }, - // ], - // }); - - // provider = testInjector.inject(PushProvider) as WebPushProvider; - // }); - - // afterEach(() => { - // vi.clearAllMocks(); - // vi.unstubAllGlobals(); - // destroyInjector(); - // }); - - // it('should be provided', () => { - // expect(provider).toBeInstanceOf(WebPushProvider); - // }); - - // describe('init() method', () => { - // it('should immediately resolve', () => { - // const callback = vi.fn(); - - // provider.init().subscribe(callback); - - // expect(callback).toHaveBeenCalled(); - // }); - // }); - - // describe('getSubscription() method', () => { - // let pushManager: PushManagerMock; - // beforeEach(async () => { - // pushManager = await getPushManager(); - // }); - - // it('should create and return Promise of subscription data from PushManager', async () => { - // const mockSubscription = { endpoint: 'mock-endpoint' }; - - // pushManager.subscription.toJSON.mockReturnValue(mockSubscription); - // pushManager.getSubscription.mockReturnValue(of(undefined)); - // provider.getSubscription().subscribe(callback); - - // expect(pushManager.subscribe).toHaveBeenCalledWith({ - // userVisibleOnly: true, - // }); - // expect(callback).toHaveBeenCalledWith(mockSubscription); - // // Should be a different object then original subscription - // expect(callback).not.toHaveBeenCalledWith(pushManager.subscription); - // }); - - // describe('when using app server key', () => { - // beforeEach(async () => { - // destroyInjector(); - - // const testInjector = createInjector({ - // providers: [ - // { - // provide: PushProvider, - // useFactory: () => - // new WebPushProvider({ - // applicationServerKey: 'mock-app&-server key==', - // }), - // }, - // ], - // }); - // provider = testInjector.inject(PushProvider) as WebPushProvider; - // pushManager = await getPushManager(); - // }); - - // it('should use app server key to create subscription if configured', async () => { - // provider.getSubscription().subscribe(callback); - // expect( - // pushManager.subscribe, - // 'applicationServerKey should be URL-safe without base64 padding' - // ).toHaveBeenCalledWith({ - // applicationServerKey: 'mock-app%26-server%20key', - // userVisibleOnly: true, - // }); - // }); - // }); - - // describe('when userVisibleOnly flag is configured', () => { - // beforeEach(async () => { - // destroyInjector(); - - // const testInjector = createInjector({ - // providers: [ - // { - // provide: PushProvider, - // useFactory: () => - // new WebPushProvider({ - // userVisibleOnly: false, - // }), - // }, - // ], - // }); - // provider = testInjector.inject(PushProvider) as WebPushProvider; - // pushManager = await getPushManager(); - // }); - // it('should use custom userVisibleOnly flag', async () => { - // provider.getSubscription().subscribe(callback); - // expect(pushManager.subscribe).toHaveBeenCalledWith({ - // userVisibleOnly: false, - // }); - // }); - // }); - - // it('should return existing subscription data if subscribed before', async () => { - // const pushSubscription = new PushSubscriptionMock(); - // const mockSubscription = { endpoint: 'mock-endpoint' }; - - // pushSubscription.toJSON.mockReturnValue(mockSubscription); - // pushManager.getSubscription.mockReturnValue(of(pushSubscription)); - - // provider.getSubscription().subscribe(callback); - // expect(pushManager.subscribe).not.toHaveBeenCalled(); - // expect(callback).toHaveBeenCalledWith(mockSubscription); - // }); - // }); - - // describe('deleteSubscription() method', () => { - // let pushManager: PushManagerMock; - // const pushSubscription = new PushSubscriptionMock(); - // describe('when subscription exists', () => { - // beforeEach(async () => { - // pushManager = await getPushManager(); - // pushManager.getSubscription.mockReturnValue(of(pushSubscription)); - // }); - // it('should cancel existing subscription and return Promise of `true` if successful', () => { - // pushSubscription.unsubscribe.mockReturnValue(of(true)); - - // provider.deleteSubscription().subscribe(callback); - // expect(callback).toHaveBeenCalledWith(true); - // }); - - // it('should cancel existing subscription and return Promise of `false` if unsuccessful', () => { - // pushSubscription.unsubscribe.mockReturnValue(of(false)); - - // provider.deleteSubscription().subscribe(callback); - // expect(callback).toHaveBeenCalledWith(false); - // }); - // }); - - // describe('when subscription does not exist', () => { - // it('should return Promise of `true`', () => { - // pushManager.getSubscription.mockReturnValue(of(null)); - - // provider.deleteSubscription().subscribe(callback); - // expect(callback).toHaveBeenCalledWith(true); - // }); - // }); - // }); + it('should be provided', () => { + expect(provider).toBeInstanceOf(WebPushProvider); + }); + + describe('init() method', () => { + it('should immediately resolve', () => { + const callback = vi.fn(); + + provider.init().subscribe(callback); + + expect(callback).toHaveBeenCalled(); + }); + }); + + describe('getSubscription() method', () => { + let pushManager: PushManagerMock; + beforeEach(async () => { + pushManager = await getPushManager(); + }); + + it('should create and return Promise of subscription data from PushManager', async () => { + const pushManager = await getPushManager(); + const mockSubscription = { endpoint: 'mock-endpoint' }; + + pushManager.subscription.toJSON.mockReturnValue(mockSubscription); + pushManager.getSubscription.mockReturnValue(of(undefined)); + provider.getSubscription().subscribe((subscription) => { + expect(pushManager.subscribe).toHaveBeenCalledWith({ + userVisibleOnly: true, + }); + expect(subscription).toBe(mockSubscription); + }); + }); + + describe('when using app server key', () => { + beforeEach(async () => { + destroyInjector(); + + const testInjector = createInjector({ + providers: [ + { + provide: PushProvider, + useFactory: () => + new WebPushProvider({ + applicationServerKey: 'mock-app&-server key==', + }), + }, + ], + }); + provider = testInjector.inject(PushProvider) as WebPushProvider; + pushManager = await getPushManager(); + }); + + it('should use app server key to create subscription if configured', async () => { + provider.getSubscription().subscribe(() => { + expect( + pushManager.subscribe, + 'applicationServerKey should be URL-safe without base64 padding' + ).toHaveBeenCalledWith({ + applicationServerKey: 'mock-app%26-server%20key', + userVisibleOnly: true, + }); + }); + }); + }); + + describe('when userVisibleOnly flag is configured', () => { + beforeEach(async () => { + destroyInjector(); + + const testInjector = createInjector({ + providers: [ + { + provide: PushProvider, + useFactory: () => + new WebPushProvider({ + userVisibleOnly: false, + }), + }, + ], + }); + provider = testInjector.inject(PushProvider) as WebPushProvider; + pushManager = await getPushManager(); + }); + it('should use custom userVisibleOnly flag', async () => { + provider.getSubscription().subscribe(() => { + expect(pushManager.subscribe).toHaveBeenCalledWith({ + userVisibleOnly: false, + }); + }); + }); + }); + + it('should return existing subscription data if subscribed before', async () => { + const pushSubscription = new PushSubscriptionMock(); + const mockSubscription = { endpoint: 'mock-endpoint' }; + + pushSubscription.toJSON.mockReturnValue(mockSubscription); + pushManager.getSubscription.mockReturnValue(of(pushSubscription)); + + provider.getSubscription().subscribe((subscription) => { + expect(pushManager.subscribe).not.toHaveBeenCalled(); + expect(subscription).toBe(mockSubscription); + }); + }); + }); + + describe('deleteSubscription() method', () => { + let pushManager: PushManagerMock; + const pushSubscription = new PushSubscriptionMock(); + describe('when subscription exists', () => { + beforeEach(async () => { + pushManager = await getPushManager(); + pushManager.getSubscription.mockReturnValue(of(pushSubscription)); + }); + it('should cancel existing subscription and return Promise of `true` if successful', () => { + pushSubscription.unsubscribe.mockReturnValue(of(true)); + + provider.deleteSubscription().subscribe((result) => { + expect(result).toBe(true); + }); + }); + + it('should cancel existing subscription and return Promise of `false` if unsuccessful', () => { + pushSubscription.unsubscribe.mockReturnValue(of(false)); + + provider.deleteSubscription().subscribe((result) => { + expect(result).toBe(false); + }); + }); + }); + + describe('when subscription does not exist', () => { + it('should return Promise of `true`', () => { + pushManager.getSubscription.mockReturnValue(of(null)); + + provider.deleteSubscription().subscribe((result) => { + expect(result).toBe(true); + }); + }); + }); + }); + + describe('error handling', () => { + describe('when service-worker API is not supported', () => { + beforeEach(() => { + vi.stubGlobal('navigator', {}); + }); + + it('should throw an error', () => { + provider.getSubscription().subscribe({ + error: (e) => { + expect(e.message).toBe( + 'Browser does not support service-worker API' + ); + }, + }); + }); + }); + + describe('when syncManager API is not supported', () => { + beforeEach(() => { + vi.stubGlobal('window', {}); + }); + + it('should throw an error', () => { + provider.getSubscription().subscribe({ + error: (e) => { + expect(e.message).toBe( + 'Browser does not support background sync API' + ); + }, + }); + }); + }); + + describe('when notifications are not allowed', () => { + beforeEach(() => { + vi.stubGlobal('navigator', { + serviceWorker: mockServiceWorker, + permissions: mockPermissions({ notification: 'denied' }), + }); + }); + + it('should throw an error', () => { + provider.getSubscription().subscribe({ + error: (e) => { + expect(e.message).toBe( + 'Permission to accept push notifications is not granted. Check the browser configuration or reset the permission' + ); + }, + }); + }); + }); + + describe('when background sync is not allowed', () => { + beforeEach(() => { + vi.stubGlobal('navigator', { + serviceWorker: mockServiceWorker, + permissions: mockPermissions({ 'background-sync': 'denied' }), + }); + }); + + it('should throw an error', () => { + provider.getSubscription().subscribe({ + error: (e) => { + expect(e.message).toBe( + 'Permission to perform background sync is not granted. Check the browser configuration or reset the permission' + ); + }, + }); + }); + }); + }); }); diff --git a/libs/platform/push-notification/web/web-push.ts b/libs/platform/push-notification/web/web-push.ts index d4585fb8dc..3cfbc3004f 100644 --- a/libs/platform/push-notification/web/web-push.ts +++ b/libs/platform/push-notification/web/web-push.ts @@ -1,5 +1,5 @@ import { PushProvider } from '@spryker-oryx/push-notification'; -import { from, map, Observable, of, switchMap } from 'rxjs'; +import { defer, from, map, Observable, of, switchMap, take } from 'rxjs'; export interface WebPushProviderOptions { /** @@ -29,35 +29,8 @@ export class WebPushProvider implements PushProvider { return of(undefined); } - protected async checkSupportAndPermission(): Promise { - let error = ''; - - if (!('serviceWorker' in navigator)) { - error = 'Browser does not support service-worker API'; - } else if (!('SyncManager' in window)) { - error = 'Browser does not support background sync API'; - } else if ( - (await navigator.permissions.query({ name: 'notifications' })).state === - 'denied' - ) { - error = - 'Permission to accept push notifications is not granted. Check the browser configuration or reset the permission'; - } else if ( - ( - await navigator.permissions.query({ - name: 'background-sync' as PermissionName, - }) - ).state === 'denied' - ) { - error = - 'Permission to perform background sync is not granted. Check the browser configuration or reset the permission'; - } - - if (error) throw new Error(error); - } - getSubscription(): Observable { - return from(this.checkSupportAndPermission()).pipe( + return this.precondition().pipe( switchMap(() => this.getExistingSubscription().pipe( switchMap((subscription) => @@ -96,6 +69,37 @@ export class WebPushProvider implements PushProvider { }; } + protected async checkSupportAndPermission(): Promise { + let error = ''; + + if (!('serviceWorker' in navigator)) { + error = 'Browser does not support service-worker API'; + } else if (!('SyncManager' in window)) { + error = 'Browser does not support background sync API'; + } else if ( + (await navigator.permissions.query({ name: 'notifications' })).state === + 'denied' + ) { + error = + 'Permission to accept push notifications is not granted. Check the browser configuration or reset the permission'; + } else if ( + ( + await navigator.permissions.query({ + name: 'background-sync' as PermissionName, + }) + ).state === 'denied' + ) { + error = + 'Permission to perform background sync is not granted. Check the browser configuration or reset the permission'; + } + + if (error) throw new Error(error); + } + + protected precondition(): Observable { + return defer(() => from(this.checkSupportAndPermission())).pipe(take(1)); + } + /** * The key should be URL-safe base64 encoded without padding (no trailing =) * @see https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#parameters From 2b5cf7573808011e963bf9445e64142200c9837e Mon Sep 17 00:00:00 2001 From: "pavel.gulvanskiy" Date: Fri, 17 Nov 2023 16:09:58 +0300 Subject: [PATCH 3/8] hrz-90415: cr fixes --- .../network-state-default.service.spec.ts | 25 +++--------- .../services/network-state-default.service.ts | 4 +- .../push-notification/web/web-push.spec.ts | 4 +- .../push-notification/web/web-push.ts | 38 +++++++++---------- 4 files changed, 27 insertions(+), 44 deletions(-) diff --git a/libs/platform/offline/src/services/network-state-default.service.spec.ts b/libs/platform/offline/src/services/network-state-default.service.spec.ts index c37420a066..b2b7f5b5f6 100644 --- a/libs/platform/offline/src/services/network-state-default.service.spec.ts +++ b/libs/platform/offline/src/services/network-state-default.service.spec.ts @@ -1,10 +1,10 @@ import { createInjector, destroyInjector } from '@spryker-oryx/di'; -import { take } from 'rxjs'; import { NetworkStateDefaultService } from './network-state-default.service'; import { NetworkStateService } from './network-state.service'; describe('NetworkStateDefaultService', () => { let service: NetworkStateService; + const callback = vi.fn(); beforeAll(() => { vi.spyOn(navigator, 'onLine', 'get').mockReturnValueOnce(true); @@ -19,6 +19,8 @@ describe('NetworkStateDefaultService', () => { }); service = testInjector.inject(NetworkStateService); + + service.online().subscribe(callback); }); afterEach(() => { @@ -27,12 +29,7 @@ describe('NetworkStateDefaultService', () => { }); it('should return default online state', () => { - service - .online() - .pipe(take(1)) - .subscribe((online) => { - expect(online).toBe(true); - }); + expect(callback).toHaveBeenCalledWith(true); }); describe('and network has gone offline', () => { @@ -42,12 +39,7 @@ describe('NetworkStateDefaultService', () => { }); it('should trigger the state', () => { - service - .online() - .pipe(take(1)) - .subscribe((online) => { - expect(online).toBe(false); - }); + expect(callback).toHaveBeenCalledWith(false); }); describe('and connection was restored', () => { @@ -57,12 +49,7 @@ describe('NetworkStateDefaultService', () => { }); it('should trigger the state', () => { - service - .online() - .pipe(take(1)) - .subscribe((online) => { - expect(online).toBe(true); - }); + expect(callback).toHaveBeenCalledWith(true); }); }); }); diff --git a/libs/platform/offline/src/services/network-state-default.service.ts b/libs/platform/offline/src/services/network-state-default.service.ts index 9d7054e29b..f201871b66 100644 --- a/libs/platform/offline/src/services/network-state-default.service.ts +++ b/libs/platform/offline/src/services/network-state-default.service.ts @@ -4,17 +4,17 @@ import { fromEvent, map, merge, - of, + startWith, } from 'rxjs'; import { NetworkStateService } from './network-state.service'; export class NetworkStateDefaultService implements NetworkStateService { online(): Observable { return merge( - of(null), fromEvent(window, 'online'), fromEvent(window, 'offline') ).pipe( + startWith(navigator.onLine), map(() => navigator.onLine), distinctUntilChanged() ); diff --git a/libs/platform/push-notification/web/web-push.spec.ts b/libs/platform/push-notification/web/web-push.spec.ts index 78a78a048a..566bd3512b 100644 --- a/libs/platform/push-notification/web/web-push.spec.ts +++ b/libs/platform/push-notification/web/web-push.spec.ts @@ -257,7 +257,7 @@ describe('WebPushProvider', () => { it('should throw an error', () => { provider.getSubscription().subscribe({ error: (e) => { - expect(e.message).toBe( + expect(e.message).toContain( 'Permission to accept push notifications is not granted. Check the browser configuration or reset the permission' ); }, @@ -276,7 +276,7 @@ describe('WebPushProvider', () => { it('should throw an error', () => { provider.getSubscription().subscribe({ error: (e) => { - expect(e.message).toBe( + expect(e.message).toContain( 'Permission to perform background sync is not granted. Check the browser configuration or reset the permission' ); }, diff --git a/libs/platform/push-notification/web/web-push.ts b/libs/platform/push-notification/web/web-push.ts index 3cfbc3004f..eb17d177e3 100644 --- a/libs/platform/push-notification/web/web-push.ts +++ b/libs/platform/push-notification/web/web-push.ts @@ -70,30 +70,19 @@ export class WebPushProvider implements PushProvider { } protected async checkSupportAndPermission(): Promise { - let error = ''; - if (!('serviceWorker' in navigator)) { - error = 'Browser does not support service-worker API'; + throw new Error('Browser does not support service-worker API'); } else if (!('SyncManager' in window)) { - error = 'Browser does not support background sync API'; - } else if ( - (await navigator.permissions.query({ name: 'notifications' })).state === - 'denied' - ) { - error = - 'Permission to accept push notifications is not granted. Check the browser configuration or reset the permission'; - } else if ( - ( - await navigator.permissions.query({ - name: 'background-sync' as PermissionName, - }) - ).state === 'denied' - ) { - error = - 'Permission to perform background sync is not granted. Check the browser configuration or reset the permission'; + throw new Error('Browser does not support background sync API'); + } else if (await this.permissionDenied('notifications')) { + throw new Error( + 'Permission to accept push notifications is not granted. Check the browser configuration or reset the permission' + ); + } else if (await this.permissionDenied('background-sync')) { + throw new Error( + 'Permission to perform background sync is not granted. Check the browser configuration or reset the permission' + ); } - - if (error) throw new Error(error); } protected precondition(): Observable { @@ -107,4 +96,11 @@ export class WebPushProvider implements PushProvider { protected encodeKey(key: string): string { return encodeURIComponent(key.replace(/([^=].)=+$/, '$1')); } + + protected async permissionDenied(name: string): Promise { + return ( + (await navigator.permissions.query({ name: name as PermissionName })) + ?.state === 'denied' + ); + } } From 01a305cd43e2f827a904dab47b7e02d5a01befb6 Mon Sep 17 00:00:00 2001 From: "pavel.gulvanskiy" Date: Fri, 17 Nov 2023 16:12:58 +0300 Subject: [PATCH 4/8] hrz-90415: minors --- libs/platform/push-notification/web/web-push.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/platform/push-notification/web/web-push.spec.ts b/libs/platform/push-notification/web/web-push.spec.ts index 566bd3512b..78a78a048a 100644 --- a/libs/platform/push-notification/web/web-push.spec.ts +++ b/libs/platform/push-notification/web/web-push.spec.ts @@ -257,7 +257,7 @@ describe('WebPushProvider', () => { it('should throw an error', () => { provider.getSubscription().subscribe({ error: (e) => { - expect(e.message).toContain( + expect(e.message).toBe( 'Permission to accept push notifications is not granted. Check the browser configuration or reset the permission' ); }, @@ -276,7 +276,7 @@ describe('WebPushProvider', () => { it('should throw an error', () => { provider.getSubscription().subscribe({ error: (e) => { - expect(e.message).toContain( + expect(e.message).toBe( 'Permission to perform background sync is not granted. Check the browser configuration or reset the permission' ); }, From 89159405bb464bfa42f4ed078612c6dc3a0d3919 Mon Sep 17 00:00:00 2001 From: "pavel.gulvanskiy" Date: Fri, 17 Nov 2023 16:25:41 +0300 Subject: [PATCH 5/8] hrz-90415: cr fixes --- .../push-notification/web/web-push.ts | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/libs/platform/push-notification/web/web-push.ts b/libs/platform/push-notification/web/web-push.ts index eb17d177e3..66f939570f 100644 --- a/libs/platform/push-notification/web/web-push.ts +++ b/libs/platform/push-notification/web/web-push.ts @@ -31,14 +31,11 @@ export class WebPushProvider implements PushProvider { getSubscription(): Observable { return this.precondition().pipe( - switchMap(() => - this.getExistingSubscription().pipe( - switchMap((subscription) => - subscription ? of(subscription) : this.createSubscription() - ), - map((subscription) => subscription.toJSON()) - ) - ) + switchMap(() => this.getExistingSubscription()), + switchMap((subscription) => + subscription ? of(subscription) : this.createSubscription() + ), + map((subscription) => subscription.toJSON()) ); } @@ -49,8 +46,18 @@ export class WebPushProvider implements PushProvider { } protected createSubscription(): Observable { + const userVisibleOnly = this.options?.userVisibleOnly ?? true; + const applicationServerKey = this.options?.applicationServerKey + ? this.encodeKey(this.options.applicationServerKey) + : undefined; + return this.pushManager$.pipe( - switchMap((pushManager) => pushManager.subscribe(this.getOptions())) + switchMap((pushManager) => + pushManager.subscribe({ + userVisibleOnly, + applicationServerKey, + }) + ) ); } @@ -60,25 +67,22 @@ export class WebPushProvider implements PushProvider { ); } - protected getOptions(): Partial { - return { - userVisibleOnly: this.options?.userVisibleOnly ?? true, - applicationServerKey: this.options?.applicationServerKey - ? this.encodeKey(this.options.applicationServerKey) - : undefined, - }; - } - protected async checkSupportAndPermission(): Promise { if (!('serviceWorker' in navigator)) { throw new Error('Browser does not support service-worker API'); - } else if (!('SyncManager' in window)) { + } + + if (!('SyncManager' in window)) { throw new Error('Browser does not support background sync API'); - } else if (await this.permissionDenied('notifications')) { + } + + if (await this.permissionDenied('notifications')) { throw new Error( 'Permission to accept push notifications is not granted. Check the browser configuration or reset the permission' ); - } else if (await this.permissionDenied('background-sync')) { + } + + if (await this.permissionDenied('background-sync')) { throw new Error( 'Permission to perform background sync is not granted. Check the browser configuration or reset the permission' ); From a360962468fc4e96a63b42bb89b925da5ba9013e Mon Sep 17 00:00:00 2001 From: "pavel.gulvanskiy" Date: Fri, 17 Nov 2023 17:06:00 +0300 Subject: [PATCH 6/8] Empty-Commit From 49fb6505d39c3212a5ade124393fc3e9da3f8487 Mon Sep 17 00:00:00 2001 From: "pavel.gulvanskiy" Date: Fri, 17 Nov 2023 17:28:51 +0300 Subject: [PATCH 7/8] Empty-Commit From ce0cf595d00fbbdd8284dc9980da038217c59519 Mon Sep 17 00:00:00 2001 From: "pavel.gulvanskiy" Date: Fri, 17 Nov 2023 17:28:54 +0300 Subject: [PATCH 8/8] Empty-Commit