diff --git a/.dockerignore b/.dockerignore index 2c36a5b152..94ff065cfb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -96,6 +96,9 @@ # from src/app/extensions/order-templates/exports/.gitignore /src/app/extensions/order-templates/exports/**/lazy* +# from src/app/extensions/product-notifications/exports/.gitignore +/src/app/extensions/product-notifications/exports/**/lazy* + # from src/app/extensions/punchout/exports/.gitignore /src/app/extensions/punchout/exports/**/lazy* diff --git a/docs/concepts/configuration.md b/docs/concepts/configuration.md index c5f540f25d..89b3d63fe3 100644 --- a/docs/concepts/configuration.md +++ b/docs/concepts/configuration.md @@ -155,6 +155,7 @@ Of course, the ICM server must supply appropriate REST resources to leverage fun | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | compare | product compare feature (additional configuration via `dataRetention` configuration options) | | contactUs | allows the user to contact the website provider via a contact web form | +| productNotifications | product notifications feature for price and in stock notifications | | rating | display product ratings | | recently | display recently viewed products (additional configuration via `dataRetention` configuration options) | | storeLocator | display physical stores and their addresses | diff --git a/docs/guides/migrations.md b/docs/guides/migrations.md index 2fa067899c..04a93178e7 100644 --- a/docs/guides/migrations.md +++ b/docs/guides/migrations.md @@ -118,6 +118,8 @@ If you want to inject a token use the methods `injectSingle` and `injectMultiple There is a new linting rule `useTypeSafeInjectionTokenRule` that enforces the usage of these methods. Find more information in the [Configuration Concept](../concepts/configuration.md#angular-cli-environments) +We introduced the product notifications feature as a new extension which is toggled with the feature toggle 'productNotifications' in the `environment.model.ts`. + ## 3.2 to 3.3 To improve the accessibility of the PWA in regards to more elements being tab focusable a lot of `[routerLink]="[]"` where added to links that previously did not have a link reference. diff --git a/e2e/cypress/e2e/pages/account/edit-product-notification.module.ts b/e2e/cypress/e2e/pages/account/edit-product-notification.module.ts new file mode 100644 index 0000000000..cf9a95d62f --- /dev/null +++ b/e2e/cypress/e2e/pages/account/edit-product-notification.module.ts @@ -0,0 +1,13 @@ +export class EditProductNotificationModule { + private priceInput = () => cy.get('ish-fieldset-field').find('[data-testing-id="priceValue"]'); + + private emailInput = () => cy.get('ish-fieldset-field').find('[data-testing-id="email"]'); + + editPriceNotification(price: number, email: string) { + this.priceInput().clear(); + this.priceInput().type(price.toString()); + this.emailInput().clear(); + this.emailInput().type(email); + cy.get('.modal-footer button.btn-primary').click(); + } +} diff --git a/e2e/cypress/e2e/pages/account/my-account.page.ts b/e2e/cypress/e2e/pages/account/my-account.page.ts index 307e1635b5..96408e1223 100644 --- a/e2e/cypress/e2e/pages/account/my-account.page.ts +++ b/e2e/cypress/e2e/pages/account/my-account.page.ts @@ -29,6 +29,10 @@ export class MyAccountPage { cy.get('a[data-testing-id="wishlists-nav-link"]').click(); } + navigateToProductNotifications() { + cy.get('a[data-testing-id="notifications-nav-link"]').click(); + } + navigateToOrderTemplates() { cy.get('a[data-testing-id="order-templates-nav-link"]').click(); } diff --git a/e2e/cypress/e2e/pages/account/product-notifications-overview.page.ts b/e2e/cypress/e2e/pages/account/product-notifications-overview.page.ts new file mode 100644 index 0000000000..442dd65bd4 --- /dev/null +++ b/e2e/cypress/e2e/pages/account/product-notifications-overview.page.ts @@ -0,0 +1,51 @@ +import { BreadcrumbModule } from '../breadcrumb.module'; +import { HeaderModule } from '../header.module'; + +export class ProductNotificationsOverviewPage { + readonly tag = 'ish-account-product-notifications-page '; + + readonly header = new HeaderModule(); + readonly breadcrumb = new BreadcrumbModule(); + + get productNotificationsArray() { + return cy.get('[data-testing-id="product-notification-list-item"]'); + } + + get productNotificationNameArray() { + return this.productNotificationsArray.find('a[data-testing-id="product-name-link"]').invoke('text'); + } + + get productNotificationMessage() { + return this.productNotificationsArray.find('div[data-testing-id="product-notification-message"]').invoke('text'); + } + + get productNotificationListItems() { + return cy.get('[data-testing-id = "product-notification-list-item"]'); + } + + get productNotificationListItemLinks() { + return this.productNotificationListItems.find('a[data-testing-id="product-name-link"]'); + } + + updateProductNotificationByProductName(productName: string, price: number, email: string) { + this.productNotificationsArray + .find('a') + .contains(productName) + .closest('[data-testing-id="product-notification-list-item"]') + .find('[data-testing-id="product-notification-edit"]') + .click(); + cy.get('[data-testing-id="priceValue"]').clear().type(price.toString()); + cy.get('[data-testing-id="email"]').clear().type(email); + cy.get('[data-testing-id="product-notification-edit-dialog-edit"]').click(); + } + + deleteProductNotificationByProductName(productName: string) { + this.productNotificationsArray + .find('a') + .contains(productName) + .closest('[data-testing-id="product-notification-list-item"]') + .find('[data-testing-id="product-notification-delete"]') + .click(); + cy.get('[data-testing-id="confirm"]').click(); + } +} diff --git a/e2e/cypress/e2e/pages/shopping/product-detail.page.ts b/e2e/cypress/e2e/pages/shopping/product-detail.page.ts index 7b0b90a9c1..e967fe8fa2 100644 --- a/e2e/cypress/e2e/pages/shopping/product-detail.page.ts +++ b/e2e/cypress/e2e/pages/shopping/product-detail.page.ts @@ -3,6 +3,7 @@ import { Interception } from 'cypress/types/net-stubbing'; import { performAddToCart, waitLoadingEnd } from '../../framework'; import { AddToOrderTemplateModule } from '../account/add-to-order-template.module'; import { AddToWishlistModule } from '../account/add-to-wishlist.module'; +import { EditProductNotificationModule } from '../account/edit-product-notification.module'; import { BreadcrumbModule } from '../breadcrumb.module'; import { HeaderModule } from '../header.module'; import { MetaDataModule } from '../meta-data.module'; @@ -23,6 +24,7 @@ export class ProductDetailPage { readonly addToWishlist = new AddToWishlistModule(); readonly addToOrderTemplate = new AddToOrderTemplateModule(); + readonly editProductNotificationModule = new EditProductNotificationModule(); reviewTab = new ProductReviewModule(); @@ -48,6 +50,10 @@ export class ProductDetailPage { private addToQuoteRequestButton = () => cy.get('ish-product-detail').find('[data-testing-id="addToQuoteButton"]'); + private editProductNotificationButton() { + return cy.get('ish-product-detail').find('[data-testing-id="product-notification-edit"]'); + } + private quantityInput = () => cy.get('ish-product-detail').find('[data-testing-id="quantity"]'); isComplete() { @@ -84,6 +90,10 @@ export class ProductDetailPage { this.addToWishlistButton().click(); } + editProductNotification() { + this.editProductNotificationButton().click(); + } + setQuantity(quantity: number) { this.quantityInput().clear(); this.quantityInput().type(quantity.toString()); diff --git a/e2e/cypress/e2e/specs/extras/price-notifications.b2c.e2e-spec.ts b/e2e/cypress/e2e/specs/extras/price-notifications.b2c.e2e-spec.ts new file mode 100644 index 0000000000..1ea087ad40 --- /dev/null +++ b/e2e/cypress/e2e/specs/extras/price-notifications.b2c.e2e-spec.ts @@ -0,0 +1,109 @@ +import { at, waitLoadingEnd } from '../../framework'; +import { createUserViaREST } from '../../framework/users'; +import { LoginPage } from '../../pages/account/login.page'; +import { MyAccountPage } from '../../pages/account/my-account.page'; +import { ProductNotificationsOverviewPage } from '../../pages/account/product-notifications-overview.page'; +import { sensibleDefaults } from '../../pages/account/registration.page'; +import { CategoryPage } from '../../pages/shopping/category.page'; +import { FamilyPage } from '../../pages/shopping/family.page'; +import { ProductDetailPage } from '../../pages/shopping/product-detail.page'; + +describe('Product Notification MyAccount Functionality', () => { + const _ = { + user: { + login: `test${new Date().getTime()}@testcity.de`, + ...sensibleDefaults, + }, + category: 'Home-Entertainment', + subcategory: 'Home-Entertainment.SmartHome', + product1: { + sku: '201807171', + name: 'Google Home', + }, + product2: { + sku: '201807191', + name: 'Philips Hue bridge', + }, + email1: 'patricia@test.intershop.de', + email2: 'test@test.intershop.de', + }; + + before(() => { + createUserViaREST(_.user); + LoginPage.navigateTo('/account/notifications'); + at(LoginPage, page => { + page.fillForm(_.user.login, _.user.password); + page.submit().its('response.statusCode').should('equal', 200); + waitLoadingEnd(); + }); + at(ProductNotificationsOverviewPage); + }); + + it('user creates two product price notifications', () => { + at(ProductNotificationsOverviewPage, page => { + page.header.gotoCategoryPage(_.category); + }); + + at(CategoryPage, page => page.gotoSubCategory(_.subcategory)); + at(FamilyPage, page => page.productList.gotoProductDetailPageBySku(_.product1.sku)); + at(ProductDetailPage, page => { + page.editProductNotification(); + page.editProductNotificationModule.editPriceNotification(150, _.email1); + page.header.goToMyAccount(); + }); + + at(MyAccountPage, page => { + page.navigateToProductNotifications(); + }); + + at(ProductNotificationsOverviewPage, page => { + page.breadcrumb.items.should('have.length', 3); + page.productNotificationNameArray.should('contain', _.product1.name); + page.productNotificationMessage.should('contain', '150'); + page.productNotificationMessage.should('contain', _.email1); + }); + + at(ProductNotificationsOverviewPage, page => { + page.header.gotoCategoryPage(_.category); + }); + + at(CategoryPage, page => page.gotoSubCategory(_.subcategory)); + at(FamilyPage, page => page.productList.gotoProductDetailPageBySku(_.product2.sku)); + at(ProductDetailPage, page => { + page.editProductNotification(); + page.editProductNotificationModule.editPriceNotification(50, _.email1); + page.header.goToMyAccount(); + }); + + at(MyAccountPage, page => { + page.navigateToProductNotifications(); + }); + + at(ProductNotificationsOverviewPage, page => { + page.productNotificationMessage.should('contain', '50'); + page.productNotificationListItemLinks.should('have.length', 2); + }); + }); + + it('user updates a product notification', () => { + at(ProductNotificationsOverviewPage, page => { + page.updateProductNotificationByProductName(_.product1.name, 130, _.email2); + + page.productNotificationMessage.should('contain', '130'); + page.productNotificationMessage.should('contain', _.email2); + }); + }); + + it('user deletes one notification', () => { + at(ProductNotificationsOverviewPage, page => { + page.productNotificationsArray.then($listItems => { + const initLen = $listItems.length; + + page.deleteProductNotificationByProductName(_.product1.name); + + page.productNotificationsArray.should('have.length', initLen - 1); + page.productNotificationNameArray.should('not.contain', _.product1.sku); + }); + }); + }); +}); diff --git a/src/app/core/facades/product-context.facade.spec.ts b/src/app/core/facades/product-context.facade.spec.ts index 0d65551d77..1226c1f42e 100644 --- a/src/app/core/facades/product-context.facade.spec.ts +++ b/src/app/core/facades/product-context.facade.spec.ts @@ -129,6 +129,7 @@ describe('Product Context Facade', () => { { "addToBasket": false, "addToCompare": true, + "addToNotification": true, "addToOrderTemplate": false, "addToQuote": false, "addToWishlist": true, @@ -328,6 +329,7 @@ describe('Product Context Facade', () => { { "addToBasket": true, "addToCompare": true, + "addToNotification": true, "addToOrderTemplate": true, "addToQuote": true, "addToWishlist": true, @@ -357,6 +359,7 @@ describe('Product Context Facade', () => { { "addToBasket": true, "addToCompare": true, + "addToNotification": true, "addToOrderTemplate": true, "addToQuote": true, "addToWishlist": true, @@ -527,6 +530,7 @@ describe('Product Context Facade', () => { { "addToBasket": true, "addToCompare": true, + "addToNotification": false, "addToOrderTemplate": true, "addToQuote": true, "addToWishlist": true, @@ -597,6 +601,7 @@ describe('Product Context Facade', () => { { "addToBasket": true, "addToCompare": true, + "addToNotification": true, "addToOrderTemplate": true, "addToQuote": true, "addToWishlist": true, @@ -641,6 +646,7 @@ describe('Product Context Facade', () => { { "addToBasket": true, "addToCompare": true, + "addToNotification": true, "addToOrderTemplate": true, "addToQuote": true, "addToWishlist": true, @@ -683,6 +689,7 @@ describe('Product Context Facade', () => { { "addToBasket": false, "addToCompare": false, + "addToNotification": false, "addToOrderTemplate": false, "addToQuote": false, "addToWishlist": false, @@ -794,6 +801,7 @@ describe('Product Context Facade', () => { { "addToBasket": true, "addToCompare": true, + "addToNotification": true, "addToOrderTemplate": true, "addToQuote": true, "addToWishlist": true, @@ -824,6 +832,7 @@ describe('Product Context Facade', () => { { "addToBasket": false, "addToCompare": false, + "addToNotification": true, "addToOrderTemplate": false, "addToQuote": false, "addToWishlist": false, @@ -859,6 +868,7 @@ describe('Product Context Facade', () => { { "addToBasket": false, "addToCompare": false, + "addToNotification": true, "addToOrderTemplate": false, "addToQuote": false, "addToWishlist": false, diff --git a/src/app/core/facades/product-context.facade.ts b/src/app/core/facades/product-context.facade.ts index 1d64d8c4fd..b246a8cdd7 100644 --- a/src/app/core/facades/product-context.facade.ts +++ b/src/app/core/facades/product-context.facade.ts @@ -52,6 +52,7 @@ export interface ProductContextDisplayProperties { addToOrderTemplate: T; addToCompare: T; addToQuote: T; + addToNotification: T; } const defaultDisplayProperties: ProductContextDisplayProperties = { @@ -72,6 +73,7 @@ const defaultDisplayProperties: ProductContextDisplayProperties + import('../store/product-notifications-store.module').then(m => m.ProductNotificationsStoreModule), + }, + multi: true, + }, + ], + declarations: [LazyProductNotificationEditComponent], + exports: [LazyProductNotificationEditComponent], +}) +export class ProductNotificationsExportsModule {} diff --git a/src/app/extensions/product-notifications/facades/product-notifications.facade.ts b/src/app/extensions/product-notifications/facades/product-notifications.facade.ts new file mode 100644 index 0000000000..353f1b1544 --- /dev/null +++ b/src/app/extensions/product-notifications/facades/product-notifications.facade.ts @@ -0,0 +1,74 @@ +import { Injectable } from '@angular/core'; +import { Store, select } from '@ngrx/store'; +import { isEqual } from 'lodash-es'; +import { Observable, distinctUntilChanged, map, switchMap } from 'rxjs'; + +import { HttpError } from 'ish-core/models/http-error/http-error.model'; +import { selectRouteParam } from 'ish-core/store/core/router'; + +import { + ProductNotification, + ProductNotificationType, +} from '../models/product-notification/product-notification.model'; +import { + getProductNotificationBySku, + getProductNotificationsByType, + getProductNotificationsError, + getProductNotificationsLoading, + productNotificationsActions, +} from '../store/product-notification'; + +/* eslint-disable @typescript-eslint/member-ordering */ +@Injectable({ providedIn: 'root' }) +export class ProductNotificationsFacade { + constructor(private store: Store) {} + + private productNotifications$(type: ProductNotificationType) { + this.store.dispatch(productNotificationsActions.loadProductNotifications({ type })); + return this.store.pipe(select(getProductNotificationsByType(type))); + } + + // get a product notification by sku and the type + productNotificationBySku$(sku: string, type: ProductNotificationType) { + this.store.dispatch(productNotificationsActions.loadProductNotifications({ type })); + + return this.store.pipe(select(getProductNotificationBySku(sku, type))).pipe( + map(notifications => (notifications?.length ? notifications[0] : undefined)), + distinctUntilChanged(isEqual) + ); + } + + // create a product notification + createProductNotification(productNotification: ProductNotification) { + this.store.dispatch(productNotificationsActions.createProductNotification({ productNotification })); + } + + // update a product notification + updateProductNotification(sku: string, productNotification: ProductNotification) { + this.store.dispatch(productNotificationsActions.updateProductNotification({ sku, productNotification })); + } + + // delete a product notification + deleteProductNotification( + sku: string, + productNotificationType: ProductNotificationType, + productNotificationId: string + ) { + this.store.dispatch( + productNotificationsActions.deleteProductNotification({ sku, productNotificationType, productNotificationId }) + ); + } + + productNotificationsLoading$: Observable = this.store.pipe(select(getProductNotificationsLoading)); + productNotificationsError$: Observable = this.store.pipe(select(getProductNotificationsError)); + + productNotificationType$ = this.store.pipe( + select(selectRouteParam('notificationType')), + distinctUntilChanged(), + map(type => type || 'price') + ); + + productNotificationsByRoute$ = this.productNotificationType$.pipe( + switchMap(type => this.productNotifications$(type as ProductNotificationType)) + ); +} diff --git a/src/app/extensions/product-notifications/models/product-notification/product-notification.interface.ts b/src/app/extensions/product-notifications/models/product-notification/product-notification.interface.ts new file mode 100644 index 0000000000..191b14c262 --- /dev/null +++ b/src/app/extensions/product-notifications/models/product-notification/product-notification.interface.ts @@ -0,0 +1,7 @@ +import { Price } from 'ish-core/models/price/price.model'; + +export interface ProductNotificationData { + sku: string; + notificationMailAddress: string; + price?: Price; +} diff --git a/src/app/extensions/product-notifications/models/product-notification/product-notification.mapper.spec.ts b/src/app/extensions/product-notifications/models/product-notification/product-notification.mapper.spec.ts new file mode 100644 index 0000000000..385ed724f2 --- /dev/null +++ b/src/app/extensions/product-notifications/models/product-notification/product-notification.mapper.spec.ts @@ -0,0 +1,51 @@ +import { ProductNotificationData } from './product-notification.interface'; +import { ProductNotificationMapper } from './product-notification.mapper'; + +describe('Product Notification Mapper', () => { + describe('fromData', () => { + it('should throw when input is falsy', () => { + expect(() => ProductNotificationMapper.fromData(undefined, undefined)).toThrow(); + }); + + it('should map incoming data for price notification to model data', () => { + const productNotificationData: ProductNotificationData = { + sku: '12345', + notificationMailAddress: 'test@test.intershop.de', + price: { type: 'Money', value: 75, currency: 'USD' }, + }; + + const mapped = ProductNotificationMapper.fromData(productNotificationData, 'price'); + expect(mapped).toMatchInlineSnapshot(` + { + "id": "12345_price", + "notificationMailAddress": "test@test.intershop.de", + "price": { + "currency": "USD", + "type": "Money", + "value": 75, + }, + "sku": "12345", + "type": "price", + } + `); + }); + + it('should map incoming data for stock notification to model data', () => { + const productNotificationData: ProductNotificationData = { + sku: '12345', + notificationMailAddress: 'test@test.intershop.de', + }; + + const mapped = ProductNotificationMapper.fromData(productNotificationData, 'stock'); + expect(mapped).toMatchInlineSnapshot(` + { + "id": "12345_stock", + "notificationMailAddress": "test@test.intershop.de", + "price": undefined, + "sku": "12345", + "type": "stock", + } + `); + }); + }); +}); diff --git a/src/app/extensions/product-notifications/models/product-notification/product-notification.mapper.ts b/src/app/extensions/product-notifications/models/product-notification/product-notification.mapper.ts new file mode 100644 index 0000000000..1a0d8af6fd --- /dev/null +++ b/src/app/extensions/product-notifications/models/product-notification/product-notification.mapper.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; + +import { ProductNotificationData } from './product-notification.interface'; +import { ProductNotification, ProductNotificationType } from './product-notification.model'; + +@Injectable({ providedIn: 'root' }) +export class ProductNotificationMapper { + static fromData( + productNotificationData: ProductNotificationData, + notificationType: ProductNotificationType + ): ProductNotification { + if (productNotificationData) { + return { + id: productNotificationData.sku.concat('_').concat(notificationType), + type: notificationType, + sku: productNotificationData.sku, + notificationMailAddress: productNotificationData.notificationMailAddress, + price: productNotificationData.price, + }; + } else { + throw new Error(`productNotificationData is required`); + } + } +} diff --git a/src/app/extensions/product-notifications/models/product-notification/product-notification.model.ts b/src/app/extensions/product-notifications/models/product-notification/product-notification.model.ts new file mode 100644 index 0000000000..77fe734c65 --- /dev/null +++ b/src/app/extensions/product-notifications/models/product-notification/product-notification.model.ts @@ -0,0 +1,11 @@ +import { Price } from 'ish-core/models/price/price.model'; + +export type ProductNotificationType = 'stock' | 'price'; + +export interface ProductNotification { + id: string; + type: ProductNotificationType; + sku: string; + notificationMailAddress: string; + price?: Price; +} diff --git a/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-list/account-product-notifications-list.component.html b/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-list/account-product-notifications-list.component.html new file mode 100644 index 0000000000..f0633f1308 --- /dev/null +++ b/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-list/account-product-notifications-list.component.html @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ {{ 'account.notifications.table.product' | translate }} + +
+ +
+
+
+ + +
+
+ {{ 'account.notifications.table.notification' | translate }} + +
+ {{ + 'account.notifications.stock.text' + | translate + : { + '0': productNotification.notificationMailAddress + } + }} +
+
+ {{ + 'account.notifications.price.text' + | translate + : { + '0': productNotification.notificationMailAddress, + '1': productNotification.price | ishPrice + } + }} +
+
+
+ + +
+
+
+ + +

{{ 'account.notifications.no_items_message' | translate }}

+
diff --git a/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-list/account-product-notifications-list.component.scss b/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-list/account-product-notifications-list.component.scss new file mode 100644 index 0000000000..7162f4e980 --- /dev/null +++ b/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-list/account-product-notifications-list.component.scss @@ -0,0 +1,17 @@ +@import 'variables'; +@import 'bootstrap/scss/mixins'; + +table.mobile-optimized, +table.mobile-optimized.table-lg { + @include media-breakpoint-only(xs) { + tr td { + &.cdk-cell { + display: block; + + .list-item { + text-align: center; + } + } + } + } +} diff --git a/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-list/account-product-notifications-list.component.spec.ts b/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-list/account-product-notifications-list.component.spec.ts new file mode 100644 index 0000000000..a772093750 --- /dev/null +++ b/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-list/account-product-notifications-list.component.spec.ts @@ -0,0 +1,101 @@ +import { CdkTableModule } from '@angular/cdk/table'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent, MockDirective } from 'ng-mocks'; + +import { ProductContextDirective } from 'ish-core/directives/product-context.directive'; +import { ProductImageComponent } from 'ish-shared/components/product/product-image/product-image.component'; +import { ProductNameComponent } from 'ish-shared/components/product/product-name/product-name.component'; +import { ProductPriceComponent } from 'ish-shared/components/product/product-price/product-price.component'; + +import { ProductNotificationType } from '../../../models/product-notification/product-notification.model'; + +import { AccountProductNotificationsListComponent } from './account-product-notifications-list.component'; + +describe('Account Product Notifications List Component', () => { + let component: AccountProductNotificationsListComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + const productNotifications = [ + { + id: '123_stock', + type: 'stock' as ProductNotificationType, + sku: '1234', + notificationMailAddress: 'test@test.intershop.de', + }, + { + id: '456_stock', + type: 'stock' as ProductNotificationType, + sku: '5678', + notificationMailAddress: 'test@test.intershop.de', + }, + ]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CdkTableModule, TranslateModule.forRoot()], + declarations: [ + AccountProductNotificationsListComponent, + MockComponent(ProductImageComponent), + MockComponent(ProductNameComponent), + MockComponent(ProductPriceComponent), + MockDirective(ProductContextDirective), + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AccountProductNotificationsListComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should display empty list text and no table if there are no product notifications', () => { + fixture.detectChanges(); + expect(element.querySelector('[data-testing-id=emptyList]')).toBeTruthy(); + + expect(element.querySelector('[data-testing-id=th-product-image]')).toBeFalsy(); + expect(element.querySelector('[data-testing-id=th-product-info]')).toBeFalsy(); + expect(element.querySelector('[data-testing-id=th-notification]')).toBeFalsy(); + }); + + it('should render table when provided with data', () => { + component.productNotifications = productNotifications; + component.columnsToDisplay = ['productImage', 'product', 'notification']; + fixture.detectChanges(); + + expect(element.querySelector('table.cdk-table')).toBeTruthy(); + expect(element.querySelectorAll('table tr.cdk-row')).toHaveLength(2); + }); + + it('should display table columns productImage if it is configured', () => { + component.productNotifications = productNotifications; + component.columnsToDisplay = ['productImage', 'product', 'notification']; + fixture.detectChanges(); + + expect(element.querySelector('[data-testing-id=th-product-image]')).toBeTruthy(); + }); + + it('should display table column product if it is configured', () => { + component.productNotifications = productNotifications; + component.columnsToDisplay = ['product']; + fixture.detectChanges(); + + expect(element.querySelector('[data-testing-id=th-product]')).toBeTruthy(); + }); + + it('should display table column notification if it is configured', () => { + component.productNotifications = productNotifications; + component.columnsToDisplay = ['notification']; + fixture.detectChanges(); + + expect(element.querySelector('[data-testing-id=th-notification]')).toBeTruthy(); + }); +}); diff --git a/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-list/account-product-notifications-list.component.ts b/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-list/account-product-notifications-list.component.ts new file mode 100644 index 0000000000..efcb8f7bcf --- /dev/null +++ b/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-list/account-product-notifications-list.component.ts @@ -0,0 +1,22 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; + +import { ProductNotification } from '../../../models/product-notification/product-notification.model'; + +type ProductNotificationsColumnsType = 'productImage' | 'product' | 'notification' | 'notificationEditDelete'; + +@Component({ + selector: 'ish-account-product-notifications-list', + templateUrl: './account-product-notifications-list.component.html', + styleUrls: ['./account-product-notifications-list.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AccountProductNotificationsListComponent { + @Input() productNotifications: ProductNotification[]; + + @Input() columnsToDisplay: ProductNotificationsColumnsType[] = [ + 'productImage', + 'product', + 'notification', + 'notificationEditDelete', + ]; +} diff --git a/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-page.component.html b/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-page.component.html new file mode 100644 index 0000000000..b2b397955b --- /dev/null +++ b/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-page.component.html @@ -0,0 +1,33 @@ +
+

{{ 'account.notifications.heading' | translate }}

+ + + + + + +
+ +
+
+ + diff --git a/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-page.component.spec.ts b/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-page.component.spec.ts new file mode 100644 index 0000000000..1a8df6e6dd --- /dev/null +++ b/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-page.component.spec.ts @@ -0,0 +1,62 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { ErrorMessageComponent } from 'ish-shared/components/common/error-message/error-message.component'; +import { LoadingComponent } from 'ish-shared/components/common/loading/loading.component'; + +import { ProductNotificationsFacade } from '../../facades/product-notifications.facade'; + +import { AccountProductNotificationsListComponent } from './account-product-notifications-list/account-product-notifications-list.component'; +import { AccountProductNotificationsPageComponent } from './account-product-notifications-page.component'; + +describe('Account Product Notifications Page Component', () => { + let component: AccountProductNotificationsPageComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + const productNotificationsFacade = mock(ProductNotificationsFacade); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NgbNavModule, RouterTestingModule, TranslateModule.forRoot()], + declarations: [ + AccountProductNotificationsPageComponent, + MockComponent(AccountProductNotificationsListComponent), + MockComponent(ErrorMessageComponent), + MockComponent(LoadingComponent), + ], + providers: [{ provide: ProductNotificationsFacade, useFactory: () => instance(productNotificationsFacade) }], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AccountProductNotificationsPageComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + + when(productNotificationsFacade.productNotificationType$).thenReturn(of('price')); + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should display loading overlay if product notifications are loading', () => { + when(productNotificationsFacade.productNotificationsLoading$).thenReturn(of(true)); + fixture.detectChanges(); + expect(element.querySelector('ish-loading')).toBeTruthy(); + }); + + it('should display price notifactions tab as active', () => { + fixture.detectChanges(); + expect(element.querySelector('[data-testing-id=tab-link-notifications-price]').getAttribute('class')).toContain( + 'active' + ); + }); +}); diff --git a/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-page.component.ts b/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-page.component.ts new file mode 100644 index 0000000000..7fc6b5c5e5 --- /dev/null +++ b/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-page.component.ts @@ -0,0 +1,46 @@ +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; +import { Observable, Subject, takeUntil } from 'rxjs'; + +import { HttpError } from 'ish-core/models/http-error/http-error.model'; + +import { ProductNotificationsFacade } from '../../facades/product-notifications.facade'; +import { + ProductNotification, + ProductNotificationType, +} from '../../models/product-notification/product-notification.model'; + +@Component({ + selector: 'ish-account-product-notifications-page', + templateUrl: './account-product-notifications-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AccountProductNotificationsPageComponent implements OnInit, OnDestroy { + productNotifications$: Observable; + productNotificationsPrice$: Observable; + productNotificationsInStock$: Observable; + productNotificationsLoading$: Observable; + productNotificationsError$: Observable; + active$: Observable; + + constructor(private productNotificationsFacade: ProductNotificationsFacade) {} + + active: ProductNotificationType; + private destroy$ = new Subject(); + + ngOnInit() { + this.productNotifications$ = this.productNotificationsFacade.productNotificationsByRoute$; + this.productNotificationsLoading$ = this.productNotificationsFacade.productNotificationsLoading$; + this.productNotificationsError$ = this.productNotificationsFacade.productNotificationsError$; + + this.active$ = this.productNotificationsFacade.productNotificationType$ as Observable; + + this.active$.pipe(takeUntil(this.destroy$)).subscribe(active => { + this.active = active; + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-page.module.ts b/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-page.module.ts new file mode 100644 index 0000000000..599442d5f3 --- /dev/null +++ b/src/app/extensions/product-notifications/pages/account-product-notifications/account-product-notifications-page.module.ts @@ -0,0 +1,28 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; + +import { SharedModule } from 'ish-shared/shared.module'; + +import { ProductNotificationsModule } from '../../product-notifications.module'; + +import { AccountProductNotificationsListComponent } from './account-product-notifications-list/account-product-notifications-list.component'; +import { AccountProductNotificationsPageComponent } from './account-product-notifications-page.component'; + +const accountProductNotificationsPageRoutes: Routes = [ + { + path: '', + component: AccountProductNotificationsPageComponent, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(accountProductNotificationsPageRoutes), + NgbNavModule, + ProductNotificationsModule, + SharedModule, + ], + declarations: [AccountProductNotificationsListComponent, AccountProductNotificationsPageComponent], +}) +export class AccountProductNotificationsPageModule {} diff --git a/src/app/extensions/product-notifications/pages/product-notifications-routing.module.ts b/src/app/extensions/product-notifications/pages/product-notifications-routing.module.ts new file mode 100644 index 0000000000..2f9dd2ebdc --- /dev/null +++ b/src/app/extensions/product-notifications/pages/product-notifications-routing.module.ts @@ -0,0 +1,23 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { featureToggleGuard } from 'ish-core/feature-toggle.module'; +import { authGuard } from 'ish-core/guards/auth.guard'; + +const routes: Routes = [ + { + path: '', + loadChildren: () => + import('./account-product-notifications/account-product-notifications-page.module').then( + m => m.AccountProductNotificationsPageModule + ), + canActivate: [featureToggleGuard, authGuard], + data: { feature: 'productNotifications' }, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class ProductNotificationsRoutingModule {} diff --git a/src/app/extensions/product-notifications/product-notifications.module.ts b/src/app/extensions/product-notifications/product-notifications.module.ts new file mode 100644 index 0000000000..faf2cae45a --- /dev/null +++ b/src/app/extensions/product-notifications/product-notifications.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; + +import { SharedModule } from 'ish-shared/shared.module'; + +import { ProductNotificationDeleteComponent } from './shared/product-notification-delete/product-notification-delete.component'; +import { ProductNotificationEditDialogComponent } from './shared/product-notification-edit-dialog/product-notification-edit-dialog.component'; +import { ProductNotificationEditFormComponent } from './shared/product-notification-edit-form/product-notification-edit-form.component'; +import { ProductNotificationEditComponent } from './shared/product-notification-edit/product-notification-edit.component'; + +@NgModule({ + imports: [SharedModule], + declarations: [ + ProductNotificationDeleteComponent, + ProductNotificationEditComponent, + ProductNotificationEditDialogComponent, + ProductNotificationEditFormComponent, + ], + exports: [ProductNotificationDeleteComponent, ProductNotificationEditComponent], +}) +export class ProductNotificationsModule {} diff --git a/src/app/extensions/product-notifications/services/product-notifications/product-notifications.service.spec.ts b/src/app/extensions/product-notifications/services/product-notifications/product-notifications.service.spec.ts new file mode 100644 index 0000000000..bef8c511a5 --- /dev/null +++ b/src/app/extensions/product-notifications/services/product-notifications/product-notifications.service.spec.ts @@ -0,0 +1,275 @@ +import { TestBed } from '@angular/core/testing'; +import { provideMockStore } from '@ngrx/store/testing'; +import { of } from 'rxjs'; +import { anything, capture, instance, mock, verify, when } from 'ts-mockito'; + +import { Customer } from 'ish-core/models/customer/customer.model'; +import { ApiService } from 'ish-core/services/api/api.service'; +import { getLoggedInCustomer } from 'ish-core/store/customer/user'; + +import { + ProductNotification, + ProductNotificationType, +} from '../../models/product-notification/product-notification.model'; + +import { ProductNotificationsService } from './product-notifications.service'; + +describe('Product Notifications Service', () => { + let apiServiceMock: ApiService; + let productNotificationsService: ProductNotificationsService; + + beforeEach(() => { + apiServiceMock = mock(ApiService); + + when(apiServiceMock.get(anything())).thenReturn(of({ sku: '1234' })); + when(apiServiceMock.resolveLinks()).thenReturn(() => of([])); + + TestBed.configureTestingModule({ + providers: [ + { provide: ApiService, useFactory: () => instance(apiServiceMock) }, + provideMockStore({ + selectors: [ + { selector: getLoggedInCustomer, value: { customerNo: '4711', isBusinessCustomer: true } as Customer }, + ], + }), + ], + }); + productNotificationsService = TestBed.inject(ProductNotificationsService); + }); + + it('should be created', () => { + expect(productNotificationsService).toBeTruthy(); + }); + + describe('getProductNotifications', () => { + beforeEach(() => { + when(apiServiceMock.get(anything())).thenReturn(of({ sku: '1234' })); + when(apiServiceMock.resolveLinks()).thenReturn(() => of([])); + }); + + it("should get product stock notifications when 'getProductNotifications' for type stock is called for b2b rest applications", done => { + productNotificationsService.getProductNotifications('stock').subscribe(data => { + verify(apiServiceMock.get(`customers/4711/users/-/notifications/stock`)).once(); + expect(data).toMatchInlineSnapshot(`[]`); + done(); + }); + }); + + it("should get product price notifications when 'getProductNotifications' for type price is called for b2b rest applications", done => { + productNotificationsService.getProductNotifications('price').subscribe(data => { + verify(apiServiceMock.get(`customers/4711/users/-/notifications/price`)).once(); + expect(data).toMatchInlineSnapshot(`[]`); + done(); + }); + }); + }); + + describe('createProductNotification', () => { + it('should return an error when called with a notification', done => { + when(apiServiceMock.post(anything())).thenReturn(of({})); + + productNotificationsService.createProductNotification(undefined).subscribe({ + next: fail, + error: err => { + expect(err).toMatchInlineSnapshot(`[Error: createProductNotification() called without notification]`); + done(); + }, + }); + + verify(apiServiceMock.post(anything())).never(); + }); + + it("should call 'createProductNotification' for creating a new price notification", done => { + const productNotificationPrice = { + sku: '12345', + type: 'price', + notificationMailAddress: 'test@test.intershop.de', + price: { type: 'Money', value: 75, currency: 'USD' }, + } as ProductNotification; + + when(apiServiceMock.post(anything(), anything())).thenReturn(of({})); + when(apiServiceMock.resolveLink()).thenReturn(() => of(productNotificationPrice)); + + productNotificationsService.createProductNotification(productNotificationPrice).subscribe(() => { + verify(apiServiceMock.post(anything(), anything())).once(); + expect(capture(apiServiceMock.post).last()[0]).toMatchInlineSnapshot( + `"customers/4711/users/-/notifications/price"` + ); + done(); + }); + }); + + it("should call 'createProductNotification' for creating a new stock notification", done => { + const productNotificationStock = { + sku: '12345', + type: 'stock', + notificationMailAddress: 'test@test.intershop.de', + } as ProductNotification; + + when(apiServiceMock.post(anything(), anything())).thenReturn(of({})); + when(apiServiceMock.resolveLink()).thenReturn(() => of(productNotificationStock)); + + productNotificationsService.createProductNotification(productNotificationStock).subscribe(() => { + verify(apiServiceMock.post(anything(), anything())).once(); + expect(capture(apiServiceMock.post).last()[0]).toMatchInlineSnapshot( + `"customers/4711/users/-/notifications/stock"` + ); + done(); + }); + }); + }); + + describe('updateProductNotification', () => { + it('should return an error when called without a sku', done => { + when(apiServiceMock.put(anything(), anything())).thenReturn(of({})); + + productNotificationsService.updateProductNotification(undefined, undefined).subscribe({ + next: fail, + error: err => { + expect(err).toMatchInlineSnapshot(`[Error: updateProductNotification() called without sku]`); + done(); + }, + }); + + verify(apiServiceMock.put(anything(), anything())).never(); + }); + + it('should return an error when called without a notification', done => { + const sku = '12345'; + + when(apiServiceMock.put(anything(), anything())).thenReturn(of({})); + + productNotificationsService.updateProductNotification(sku, undefined).subscribe({ + next: fail, + error: err => { + expect(err).toMatchInlineSnapshot(`[Error: updateProductNotification() called without notification]`); + done(); + }, + }); + + verify(apiServiceMock.put(anything(), anything())).never(); + }); + + it("should call 'updateProductNotification' for updating a price notification", done => { + const sku = '12345'; + const productNotificationPrice = { + id: '12345_price', + sku: '12345', + type: 'price', + notificationMailAddress: 'test@test.intershop.de', + price: { type: 'Money', value: 75, currency: 'USD' }, + } as ProductNotification; + + when(apiServiceMock.put(anything(), anything())).thenReturn(of(productNotificationPrice)); + when(apiServiceMock.resolveLink(anything())).thenReturn(() => of(productNotificationPrice)); + + productNotificationsService.updateProductNotification(sku, productNotificationPrice).subscribe(() => { + verify(apiServiceMock.put(anything(), anything())).once(); + expect(capture(apiServiceMock.put).last()[0]).toMatchInlineSnapshot( + `"customers/4711/users/-/notifications/price/12345"` + ); + done(); + }); + }); + + it("should call 'updateProductNotification' for updating a stock notification", done => { + const sku = '12345'; + const productNotificationStock = { + id: '12345_stock', + sku: '12345', + type: 'stock', + notificationMailAddress: 'test@test.intershop.de', + price: { type: 'Money', value: 75, currency: 'USD' }, + } as ProductNotification; + + when(apiServiceMock.put(anything(), anything())).thenReturn(of(productNotificationStock)); + when(apiServiceMock.resolveLink(anything())).thenReturn(() => of(productNotificationStock)); + + productNotificationsService.updateProductNotification(sku, productNotificationStock).subscribe(() => { + verify(apiServiceMock.put(anything(), anything())).once(); + expect(capture(apiServiceMock.put).last()[0]).toMatchInlineSnapshot( + `"customers/4711/users/-/notifications/stock/12345"` + ); + done(); + }); + }); + }); + + describe('deleteProductNotification', () => { + it('should return an error when called without a sku', done => { + const type: ProductNotificationType = 'price'; + + when(apiServiceMock.delete(anything(), anything())).thenReturn(of({})); + + productNotificationsService.deleteProductNotification(undefined, type).subscribe({ + next: fail, + error: err => { + expect(err).toMatchInlineSnapshot(`[Error: deleteProductNotification() called without sku]`); + done(); + }, + }); + + verify(apiServiceMock.delete(anything(), anything())).never(); + }); + + it('should return an error when called without a type', done => { + const sku = '12345'; + + when(apiServiceMock.delete(anything(), anything())).thenReturn(of({})); + + productNotificationsService.deleteProductNotification(sku, undefined).subscribe({ + next: fail, + error: err => { + expect(err).toMatchInlineSnapshot(`[Error: deleteProductNotification() called without type]`); + done(); + }, + }); + + verify(apiServiceMock.delete(anything(), anything())).never(); + }); + + it("should call 'deleteProductNotification' for deleting a price notification", done => { + const sku = '12345'; + const type: ProductNotificationType = 'price'; + + when(apiServiceMock.delete(anything())).thenReturn(of({})); + when(apiServiceMock.resolveLink(anything())).thenReturn(() => of({})); + + productNotificationsService.deleteProductNotification(sku, type).subscribe(() => { + verify(apiServiceMock.delete(anything())).once(); + expect(capture(apiServiceMock.delete).last()[0]).toMatchInlineSnapshot( + `"customers/4711/users/-/notifications/price/12345"` + ); + done(); + }); + }); + + it("should call 'deleteProductNotification' for deleting a stock notification", done => { + const sku = '12345'; + const type: ProductNotificationType = 'stock'; + + when(apiServiceMock.delete(anything())).thenReturn(of({})); + when(apiServiceMock.resolveLink(anything())).thenReturn(() => of({})); + + productNotificationsService.deleteProductNotification(sku, type).subscribe(() => { + verify(apiServiceMock.delete(anything())).once(); + expect(capture(apiServiceMock.delete).last()[0]).toMatchInlineSnapshot( + `"customers/4711/users/-/notifications/stock/12345"` + ); + done(); + }); + }); + + it("should delete a notification when 'deleteProductNotification' is called", done => { + const sku = '12345'; + const type: ProductNotificationType = 'price'; + + when(apiServiceMock.delete(`customers/4711/users/-/notifications/${type}/${sku}`)).thenReturn(of({})); + + productNotificationsService.deleteProductNotification(sku, type).subscribe(() => { + verify(apiServiceMock.delete(`customers/4711/users/-/notifications/${type}/${sku}`)).once(); + done(); + }); + }); + }); +}); diff --git a/src/app/extensions/product-notifications/services/product-notifications/product-notifications.service.ts b/src/app/extensions/product-notifications/services/product-notifications/product-notifications.service.ts new file mode 100644 index 0000000000..9172323283 --- /dev/null +++ b/src/app/extensions/product-notifications/services/product-notifications/product-notifications.service.ts @@ -0,0 +1,135 @@ +import { Injectable } from '@angular/core'; +import { Store, select } from '@ngrx/store'; +import { Observable, map, switchMap, take, throwError } from 'rxjs'; + +import { Link } from 'ish-core/models/link/link.model'; +import { ApiService, unpackEnvelope } from 'ish-core/services/api/api.service'; +import { getLoggedInCustomer } from 'ish-core/store/customer/user'; +import { whenTruthy } from 'ish-core/utils/operators'; + +import { ProductNotificationData } from '../../models/product-notification/product-notification.interface'; +import { ProductNotificationMapper } from '../../models/product-notification/product-notification.mapper'; +import { + ProductNotification, + ProductNotificationType, +} from '../../models/product-notification/product-notification.model'; + +@Injectable({ providedIn: 'root' }) +export class ProductNotificationsService { + constructor(private apiService: ApiService, private store: Store) {} + + private currentCustomer$ = this.store.pipe(select(getLoggedInCustomer), whenTruthy(), take(1)); + + /** + * Get all product notifications of a specific type from a customer. + * + * @param notificationType The type of the product notifications. + * Possible types are 'price' (a specific product price is reached) and 'stock' (product is back in stock) + * @returns All product notifications of a specific type from the customer. + */ + getProductNotifications(notificationType: ProductNotificationType): Observable { + return this.currentCustomer$.pipe( + switchMap(customer => + this.apiService + .get( + customer.isBusinessCustomer + ? `customers/${customer.customerNo}/users/-/notifications/${notificationType}` + : `privatecustomers/-/notifications/${notificationType}` + ) + .pipe( + unpackEnvelope(), + this.apiService.resolveLinks(), + map(data => + data.map(notificationData => ProductNotificationMapper.fromData(notificationData, notificationType)) + ) + ) + ) + ); + } + + /** + * Creates a product notification for a given product and user. + * + * @param productNotification The product notification. + * @returns The created product notification. + */ + createProductNotification(productNotification: ProductNotification) { + if (!productNotification) { + return throwError(() => new Error('createProductNotification() called without notification')); + } + + return this.currentCustomer$.pipe( + switchMap(customer => + this.apiService + .post( + customer.isBusinessCustomer + ? `customers/${customer.customerNo}/users/-/notifications/${productNotification.type}` + : `privatecustomers/-/notifications/${productNotification.type}`, + productNotification + ) + .pipe( + this.apiService.resolveLink(), + map(notificationData => ProductNotificationMapper.fromData(notificationData, productNotification.type)) + ) + ) + ); + } + + /** + * Updates a product notification for a given product and user. + * + * @param sku The product sku. + * @param productNotification The product notification. + * @returns The updated product notification. + */ + updateProductNotification(sku: string, productNotification: ProductNotification) { + if (!sku) { + return throwError(() => new Error('updateProductNotification() called without sku')); + } + if (!productNotification) { + return throwError(() => new Error('updateProductNotification() called without notification')); + } + + return this.currentCustomer$.pipe( + switchMap(customer => + this.apiService + .put( + customer.isBusinessCustomer + ? `customers/${customer.customerNo}/users/-/notifications/${productNotification.type}/${sku}` + : `privatecustomers/-/notifications/${productNotification.type}/${sku}`, + productNotification + ) + .pipe( + map((response: ProductNotification) => + ProductNotificationMapper.fromData(response, productNotification.type) + ) + ) + ) + ); + } + + /** + * Deletes a product notification for a given product and user. + * + * @param sku The product sku. + * @param productNotificationType The type of the product notification. + */ + deleteProductNotification(sku: string, productNotificationType: ProductNotificationType) { + if (!sku) { + return throwError(() => new Error('deleteProductNotification() called without sku')); + } + if (!productNotificationType) { + return throwError(() => new Error('deleteProductNotification() called without type')); + } + + return this.currentCustomer$.pipe( + switchMap(customer => + this.apiService.delete( + customer.isBusinessCustomer + ? `customers/${customer.customerNo}/users/-/notifications/${productNotificationType}/${sku}` + : `privatecustomers/-/notifications/${productNotificationType}/${sku}` + ) + ) + ); + } +} diff --git a/src/app/extensions/product-notifications/shared/product-notification-delete/product-notification-delete.component.html b/src/app/extensions/product-notifications/shared/product-notification-delete/product-notification-delete.component.html new file mode 100644 index 0000000000..10aee15c0a --- /dev/null +++ b/src/app/extensions/product-notifications/shared/product-notification-delete/product-notification-delete.component.html @@ -0,0 +1,21 @@ + + + +
+
diff --git a/src/app/extensions/product-notifications/shared/product-notification-delete/product-notification-delete.component.spec.ts b/src/app/extensions/product-notifications/shared/product-notification-delete/product-notification-delete.component.spec.ts new file mode 100644 index 0000000000..d5e2607cb3 --- /dev/null +++ b/src/app/extensions/product-notifications/shared/product-notification-delete/product-notification-delete.component.spec.ts @@ -0,0 +1,56 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent, MockDirective } from 'ng-mocks'; +import { instance, mock } from 'ts-mockito'; + +import { ServerHtmlDirective } from 'ish-core/directives/server-html.directive'; +import { ProductContextFacade } from 'ish-core/facades/product-context.facade'; +import { ModalDialogComponent } from 'ish-shared/components/common/modal-dialog/modal-dialog.component'; + +import { ProductNotificationsFacade } from '../../facades/product-notifications.facade'; + +import { ProductNotificationDeleteComponent } from './product-notification-delete.component'; + +describe('Product Notification Delete Component', () => { + let component: ProductNotificationDeleteComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let context: ProductContextFacade; + let productNotificationsFacade: ProductNotificationsFacade; + + beforeEach(async () => { + context = mock(ProductContextFacade); + productNotificationsFacade = mock(ProductNotificationsFacade); + await TestBed.configureTestingModule({ + declarations: [ + MockComponent(FaIconComponent), + MockComponent(ModalDialogComponent), + MockDirective(ServerHtmlDirective), + ProductNotificationDeleteComponent, + ], + imports: [TranslateModule.forRoot()], + providers: [ + { provide: ProductContextFacade, useFactory: () => instance(context) }, + { provide: ProductNotificationsFacade, useFactory: () => instance(productNotificationsFacade) }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ProductNotificationDeleteComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should include the delete notification modal dialog', () => { + fixture.detectChanges(); + expect(element.querySelector('ish-modal-dialog')).toBeTruthy(); + }); +}); diff --git a/src/app/extensions/product-notifications/shared/product-notification-delete/product-notification-delete.component.ts b/src/app/extensions/product-notifications/shared/product-notification-delete/product-notification-delete.component.ts new file mode 100644 index 0000000000..1cb080d235 --- /dev/null +++ b/src/app/extensions/product-notifications/shared/product-notification-delete/product-notification-delete.component.ts @@ -0,0 +1,49 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { ProductContextFacade } from 'ish-core/facades/product-context.facade'; +import { ModalDialogComponent } from 'ish-shared/components/common/modal-dialog/modal-dialog.component'; + +import { ProductNotificationsFacade } from '../../facades/product-notifications.facade'; +import { ProductNotification } from '../../models/product-notification/product-notification.model'; + +/** + * The Product Notification Delete Component shows the customer a link to open the dialog + * to delete the product notification. + * + * @example + * + */ +@Component({ + selector: 'ish-product-notification-delete', + templateUrl: './product-notification-delete.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProductNotificationDeleteComponent implements OnInit { + @Input() productNotification: ProductNotification; + @Input() cssClass: string; + + productName$: Observable; + + constructor(private productNotificationsFacade: ProductNotificationsFacade, private context: ProductContextFacade) {} + + ngOnInit() { + this.productName$ = this.context.select('product', 'name'); + } + + openConfirmationDialog(modal: ModalDialogComponent) { + modal.show(); + } + + // delete the notification + deleteProductNotification() { + const sku = this.context.get('sku'); + const productNotificationType = this.productNotification.type; + const productNotificationId = this.productNotification.id; + + this.productNotificationsFacade.deleteProductNotification(sku, productNotificationType, productNotificationId); + } +} diff --git a/src/app/extensions/product-notifications/shared/product-notification-edit-dialog/product-notification-edit-dialog.component.html b/src/app/extensions/product-notifications/shared/product-notification-edit-dialog/product-notification-edit-dialog.component.html new file mode 100644 index 0000000000..3c69e574bb --- /dev/null +++ b/src/app/extensions/product-notifications/shared/product-notification-edit-dialog/product-notification-edit-dialog.component.html @@ -0,0 +1,61 @@ + + + + +
+ + +
+
+ + +
+ + +
+
+
diff --git a/src/app/extensions/product-notifications/shared/product-notification-edit-dialog/product-notification-edit-dialog.component.spec.ts b/src/app/extensions/product-notifications/shared/product-notification-edit-dialog/product-notification-edit-dialog.component.spec.ts new file mode 100644 index 0000000000..b74ce87638 --- /dev/null +++ b/src/app/extensions/product-notifications/shared/product-notification-edit-dialog/product-notification-edit-dialog.component.spec.ts @@ -0,0 +1,123 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { UntypedFormBuilder, Validators } from '@angular/forms'; +import { of } from 'rxjs'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; + +import { AccountFacade } from 'ish-core/facades/account.facade'; +import { AppFacade } from 'ish-core/facades/app.facade'; +import { ProductContextFacade } from 'ish-core/facades/product-context.facade'; +import { SpecialValidators } from 'ish-shared/forms/validators/special-validators'; + +import { ProductNotificationsFacade } from '../../facades/product-notifications.facade'; + +import { ProductNotificationEditDialogComponent } from './product-notification-edit-dialog.component'; + +describe('Product Notification Edit Dialog Component', () => { + let component: ProductNotificationEditDialogComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let context: ProductContextFacade; + let productNotificationsFacade: ProductNotificationsFacade; + let accountFacade: AccountFacade; + let appFacade: AppFacade; + let fb: UntypedFormBuilder; + + beforeEach(async () => { + context = mock(ProductContextFacade); + productNotificationsFacade = mock(ProductNotificationsFacade); + accountFacade = mock(accountFacade); + appFacade = mock(AppFacade); + + await TestBed.configureTestingModule({ + providers: [ + { provide: AccountFacade, useFactory: () => instance(accountFacade) }, + { provide: AppFacade, useFactory: () => instance(appFacade) }, + { provide: ProductContextFacade, useFactory: () => instance(context) }, + { provide: ProductNotificationsFacade, useFactory: () => instance(productNotificationsFacade) }, + ], + }).compileComponents(); + + when(appFacade.currentCurrency$).thenReturn(of('USD')); + when(context.select('product', 'available')).thenReturn(of(true)); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ProductNotificationEditDialogComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + describe('form submit', () => { + beforeEach(() => { + fb = TestBed.inject(UntypedFormBuilder); + }); + + it('should submit a valid form when the user fills all required fields', () => { + component.productNotificationForm = fb.group({ + alertType: ['price'], + email: ['jlink@test.intershop.de', [Validators.required, SpecialValidators.email]], + priceValue: [1000, [SpecialValidators.moneyAmount]], + }); + + expect(component.formDisabled).toBeFalse(); + component.submitForm(); + expect(component.formDisabled).toBeFalse(); + }); + + it('should not submit a form when the user does not provide money format for price notification', () => { + component.productNotificationForm = fb.group({ + priceValue: ['abc', [SpecialValidators.moneyAmount]], + }); + + expect(component.formDisabled).toBeFalse(); + component.submitForm(); + expect(component.formDisabled).toBeTrue(); + }); + + it('should emit delete product notification when alert type is delete', () => { + component.productNotificationForm = fb.group({ + alertType: ['delete'], + }); + + when(productNotificationsFacade.deleteProductNotification(anything(), anything(), anything())).thenReturn(); + component.submitForm(); + verify(productNotificationsFacade.deleteProductNotification(anything(), anything(), anything())).once(); + }); + + it('should emit update product notification when alert type is price', () => { + component.productNotificationForm = fb.group({ + alertType: ['price'], + }); + + when(productNotificationsFacade.updateProductNotification(anything(), anything())).thenReturn(); + component.submitForm(); + verify(productNotificationsFacade.updateProductNotification(anything(), anything())).once(); + }); + + it('should emit update product notification when alert type is stock', () => { + component.productNotificationForm = fb.group({ + alertType: ['stock'], + }); + + when(productNotificationsFacade.updateProductNotification(anything(), anything())).thenReturn(); + component.submitForm(); + verify(productNotificationsFacade.updateProductNotification(anything(), anything())).once(); + }); + + it('should emit create product notification when alert type is not set', () => { + component.productNotificationForm = fb.group({ + alertType: undefined, + }); + + when(productNotificationsFacade.createProductNotification(anything())).thenReturn(); + component.submitForm(); + verify(productNotificationsFacade.createProductNotification(anything())).once(); + }); + }); +}); diff --git a/src/app/extensions/product-notifications/shared/product-notification-edit-dialog/product-notification-edit-dialog.component.ts b/src/app/extensions/product-notifications/shared/product-notification-edit-dialog/product-notification-edit-dialog.component.ts new file mode 100644 index 0000000000..a66d228b94 --- /dev/null +++ b/src/app/extensions/product-notifications/shared/product-notification-edit-dialog/product-notification-edit-dialog.component.ts @@ -0,0 +1,148 @@ +import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core'; +import { UntypedFormGroup } from '@angular/forms'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { Observable, Subject, of, shareReplay, switchMap, takeUntil } from 'rxjs'; + +import { AccountFacade } from 'ish-core/facades/account.facade'; +import { AppFacade } from 'ish-core/facades/app.facade'; +import { ProductContextFacade } from 'ish-core/facades/product-context.facade'; +import { ProductView } from 'ish-core/models/product-view/product-view.model'; +import { whenTruthy } from 'ish-core/utils/operators'; +import { markAsDirtyRecursive } from 'ish-shared/forms/utils/form-utils'; + +import { ProductNotificationsFacade } from '../../facades/product-notifications.facade'; +import { ProductNotification } from '../../models/product-notification/product-notification.model'; + +/** + * The Product Notification Edit Dialog Component shows the customer a dialog to either create, + * edit or delete a product notification. The dialog is called from either the detail page or + * the my account notifications list. + * If a product notification does not exist yet, the dialog shows the form to create a notification. + * If a product notification exists, the dialog shows the form to update the notification. + * + * Each form includes it's specific form elements and buttons, see product-notification-edit-form.component. + * + * + * @example + * + */ +@Component({ + selector: 'ish-product-notification-edit-dialog', + templateUrl: './product-notification-edit-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProductNotificationEditDialogComponent implements OnInit, OnDestroy { + @Input() productNotification: ProductNotification; + + modal: NgbModalRef; + product$: Observable; + productAvailable$: Observable; + userEmail$: Observable; + currentCurrency: string; + + productNotificationForm = new UntypedFormGroup({}); + submitted = false; + + productNotification$: Observable; + + private destroy$ = new Subject(); + + @ViewChild('modal', { static: false }) modalTemplate: TemplateRef; + + constructor( + private ngbModal: NgbModal, + private context: ProductContextFacade, + private accountFacade: AccountFacade, + private productNotificationsFacade: ProductNotificationsFacade, + private appFacade: AppFacade + ) {} + + ngOnInit() { + this.product$ = this.context.select('product'); + this.productAvailable$ = this.context.select('product', 'available'); + this.userEmail$ = this.accountFacade.userEmail$; + + // determine current currency + this.appFacade.currentCurrency$.pipe(whenTruthy(), takeUntil(this.destroy$)).subscribe(currency => { + this.currentCurrency = currency; + }); + + // if no product notification is given as @Input parameter, trigger a REST call to fetch the notification + this.productNotification$ = this.productNotification + ? of(this.productNotification) + : this.productAvailable$.pipe( + switchMap(available => + this.productNotificationsFacade + .productNotificationBySku$(this.context.get('sku'), available ? 'price' : 'stock') + .pipe(shareReplay(1)) + ) + ); + } + + // close modal + hide() { + this.productNotificationForm.reset(); + if (this.modal) { + this.modal.close(); + } + } + + // open modal + show() { + this.modal = this.ngbModal.open(this.modalTemplate); + } + + get formDisabled() { + return this.productNotificationForm.invalid && this.submitted; + } + + // submit the form + submitForm() { + if (this.productNotificationForm.invalid) { + markAsDirtyRecursive(this.productNotificationForm); + this.submitted = true; + return; + } else { + const sku = this.context.get('sku'); + const formValue = this.productNotificationForm.value; + const productNotificationType = formValue.priceValue === undefined ? 'stock' : 'price'; + const productNotificationId = + formValue.productNotificationId !== undefined ? formValue.productNotificationId : ''; + + const productNotification: ProductNotification = { + id: undefined, + type: productNotificationType, + sku, + notificationMailAddress: this.productNotificationForm.value.email, + price: { + type: 'Money', + value: formValue.priceValue, + currency: this.currentCurrency, + }, + }; + + if (formValue.alertType !== undefined && formValue.alertType === 'delete') { + // user selected the radio button to delete the notification + this.productNotificationsFacade.deleteProductNotification(sku, productNotificationType, productNotificationId); + } else if ( + formValue.alertType !== undefined && + (formValue.alertType === 'price' || formValue.alertType === 'stock') + ) { + // update existing notification + this.productNotificationsFacade.updateProductNotification(sku, productNotification); + } else { + // there is no radio button + this.productNotificationsFacade.createProductNotification(productNotification); + } + + this.hide(); + } + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/src/app/extensions/product-notifications/shared/product-notification-edit-form/product-notification-edit-form.component.html b/src/app/extensions/product-notifications/shared/product-notification-edit-form/product-notification-edit-form.component.html new file mode 100644 index 0000000000..095164aab5 --- /dev/null +++ b/src/app/extensions/product-notifications/shared/product-notification-edit-form/product-notification-edit-form.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/src/app/extensions/product-notifications/shared/product-notification-edit-form/product-notification-edit-form.component.spec.ts b/src/app/extensions/product-notifications/shared/product-notification-edit-form/product-notification-edit-form.component.spec.ts new file mode 100644 index 0000000000..ee1ef38c16 --- /dev/null +++ b/src/app/extensions/product-notifications/shared/product-notification-edit-form/product-notification-edit-form.component.spec.ts @@ -0,0 +1,108 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { instance, mock } from 'ts-mockito'; + +import { AppFacade } from 'ish-core/facades/app.facade'; +import { ProductContextFacade } from 'ish-core/facades/product-context.facade'; +import { ProductView } from 'ish-core/models/product-view/product-view.model'; +import { extractKeys } from 'ish-shared/formly/dev/testing/formly-testing-utils'; + +import { ProductNotification } from '../../models/product-notification/product-notification.model'; + +import { ProductNotificationEditFormComponent } from './product-notification-edit-form.component'; + +describe('Product Notification Edit Form Component', () => { + let component: ProductNotificationEditFormComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let context: ProductContextFacade; + let appFacade: AppFacade; + const product: ProductView = {} as ProductView; + + beforeEach(async () => { + context = mock(ProductContextFacade); + appFacade = mock(AppFacade); + + await TestBed.configureTestingModule({ + providers: [ + { provide: AppFacade, useFactory: () => instance(appFacade) }, + { provide: ProductContextFacade, useFactory: () => instance(context) }, + ], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ProductNotificationEditFormComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + describe('form fields configuration', () => { + it('should return correct fields when calling getFieldsForNoProductNotification if product is not available', () => { + expect(extractKeys(component.getFieldsForNoProductNotification(product))).toMatchInlineSnapshot(` + [ + "alertType", + [ + "email", + ], + ] + `); + }); + + it('should return correct fields when calling getFieldsForNoProductNotification if product is available', () => { + product.available = true; + + expect(extractKeys(component.getFieldsForNoProductNotification(product))).toMatchInlineSnapshot(` + [ + "alertType", + [ + "priceValue", + "email", + ], + ] + `); + }); + + it('should return correct fields when calling getFieldsForProductNotification for price notification', () => { + component.productNotification = { + type: 'price', + } as ProductNotification; + + product.available = true; + + expect(extractKeys(component.getFieldsForProductNotification(component.productNotification, product))) + .toMatchInlineSnapshot(` + [ + "alertType", + "productNotificationId", + "alertType", + "priceValue", + "email", + ] + `); + }); + + it('should return correct fields when calling getFieldsForProductNotification for stock notification', () => { + component.productNotification = { + type: 'stock', + } as ProductNotification; + + product.available = false; + + expect(extractKeys(component.getFieldsForProductNotification(component.productNotification, product))) + .toMatchInlineSnapshot(` + [ + "alertType", + "productNotificationId", + "alertType", + "email", + ] + `); + }); + }); +}); diff --git a/src/app/extensions/product-notifications/shared/product-notification-edit-form/product-notification-edit-form.component.ts b/src/app/extensions/product-notifications/shared/product-notification-edit-form/product-notification-edit-form.component.ts new file mode 100644 index 0000000000..a687ea764e --- /dev/null +++ b/src/app/extensions/product-notifications/shared/product-notification-edit-form/product-notification-edit-form.component.ts @@ -0,0 +1,200 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges } from '@angular/core'; +import { FormGroup } from '@angular/forms'; +import { FormlyFieldConfig } from '@ngx-formly/core'; + +import { AppFacade } from 'ish-core/facades/app.facade'; +import { ProductContextFacade } from 'ish-core/facades/product-context.facade'; +import { Pricing } from 'ish-core/models/price/price.model'; +import { ProductView } from 'ish-core/models/product-view/product-view.model'; +import { SpecialValidators } from 'ish-shared/forms/validators/special-validators'; + +import { ProductNotification } from '../../models/product-notification/product-notification.model'; + +/** + * The Product Notification Form Component includes the form to edit the notification. + * It is shown within the product notification dialog component. + * + * The form shows fields either for the price or the stock notification. + * Each of these two types has to support two cases: + * - A product notification is not available and has to be created. There are no radio buttons. + * - A product notification is available and it can be edited or deleted. In this case, + * there are radio buttons used to either delete the notification or to edit it. + * + * @example + * + */ +@Component({ + selector: 'ish-product-notification-edit-form', + templateUrl: './product-notification-edit-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProductNotificationEditFormComponent implements OnChanges { + @Input() form: FormGroup; + @Input() productNotification: ProductNotification; + @Input() userEmail: string; + + product: ProductView; + productPrices: Pricing; + + model: { + alertType?: string; + email: string; + priceValue: number; + productNotificationId?: string; + }; + + fields: FormlyFieldConfig[]; + + constructor(private appFacade: AppFacade, private context: ProductContextFacade) {} + + ngOnChanges() { + if (this.userEmail && this.form) { + this.product = this.context.get('product'); + this.productPrices = this.context.get('prices'); + + // fill the form values in the form model, this.productNotification can be "undefined" if no notification exists + this.model = this.productNotification + ? { + alertType: this.productNotification.type, + email: this.productNotification.notificationMailAddress, + priceValue: this.productNotification.price?.value, + productNotificationId: this.productNotification.id, + } + : { + alertType: undefined, + email: this.userEmail, + priceValue: this.productPrices.salePrice.value, + }; + + // differentiate form with or without a product notification + this.fields = this.productNotification + ? this.getFieldsForProductNotification(this.productNotification, this.product) + : this.getFieldsForNoProductNotification(this.product); + } + } + + // get form fields if a product notification is available + getFieldsForProductNotification(productNotification: ProductNotification, product: ProductView): FormlyFieldConfig[] { + return [ + { + key: 'alertType', + type: 'ish-radio-field', + props: { + label: 'product.notification.edit.form.no_notification.label', + fieldClass: 'py-0', + value: 'delete', + }, + }, + { + key: 'productNotificationId', + }, + ...(productNotification?.type === 'price' || product?.available + ? this.getPriceConfigForProductNotification() + : []), + ...(productNotification?.type === 'stock' || !product?.available + ? this.getStockConfigForProductNotification() + : []), + ...this.getEmailConfig(), + ]; + } + + // wrap form fields in fieldset if a product notification is not available because there are no radio buttons + getFieldsForNoProductNotification(product: ProductView): FormlyFieldConfig[] { + return [ + { + key: 'alertType', + props: { + value: '', + }, + }, + { + type: 'ish-fieldset-field', + props: { + legend: product?.available + ? 'product.notification.edit.form.price_notification.label' + : 'product.notification.edit.form.instock_notification.label', + legendClass: 'row mb-3', + }, + fieldGroup: [...(product?.available ? this.getPriceConfig() : []), ...this.getEmailConfig()], + }, + ]; + } + + // get the fields for the price notification + private getPriceConfigForProductNotification(): FormlyFieldConfig[] { + return [ + { + key: 'alertType', + type: 'ish-radio-field', + props: { + label: 'product.notification.edit.form.price_notification.label', + fieldClass: 'py-0', + value: 'price', + }, + }, + ...this.getPriceConfig(), + ]; + } + + // get the fields for the stock notification + private getStockConfigForProductNotification(): FormlyFieldConfig[] { + return [ + { + key: 'alertType', + type: 'ish-radio-field', + props: { + label: 'product.notification.edit.form.instock_notification.label', + fieldClass: 'py-0', + value: 'stock', + }, + }, + ]; + } + + // get only the price field + private getPriceConfig(): FormlyFieldConfig[] { + return [ + { + key: 'priceValue', + type: 'ish-text-input-field', + props: { + postWrappers: [{ wrapper: 'input-addon', index: -1 }], + label: 'product.notification.edit.form.price.label', + required: true, + hideRequiredMarker: true, + addonLeft: { + text: this.appFacade.currencySymbol$(), + }, + }, + validators: { + validation: [SpecialValidators.moneyAmount], + }, + validation: { + messages: { + required: 'product.notification.edit.form.price.error.required', + moneyAmount: 'product.notification.edit.form.price.error.valid', + }, + }, + }, + ]; + } + + // get only the email field + private getEmailConfig(): FormlyFieldConfig[] { + return [ + { + key: 'email', + type: 'ish-email-field', + props: { + label: 'product.notification.edit.form.email.label', + required: true, + hideRequiredMarker: true, + }, + }, + ]; + } +} diff --git a/src/app/extensions/product-notifications/shared/product-notification-edit/product-notification-edit.component.html b/src/app/extensions/product-notifications/shared/product-notification-edit/product-notification-edit.component.html new file mode 100644 index 0000000000..a279250f87 --- /dev/null +++ b/src/app/extensions/product-notifications/shared/product-notification-edit/product-notification-edit.component.html @@ -0,0 +1,33 @@ + + + + + + + + + + + + diff --git a/src/app/extensions/product-notifications/shared/product-notification-edit/product-notification-edit.component.spec.ts b/src/app/extensions/product-notifications/shared/product-notification-edit/product-notification-edit.component.spec.ts new file mode 100644 index 0000000000..7c2ce9a17a --- /dev/null +++ b/src/app/extensions/product-notifications/shared/product-notification-edit/product-notification-edit.component.spec.ts @@ -0,0 +1,91 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; +import { of } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { AccountFacade } from 'ish-core/facades/account.facade'; +import { ProductContextFacade } from 'ish-core/facades/product-context.facade'; + +import { ProductNotificationEditDialogComponent } from '../product-notification-edit-dialog/product-notification-edit-dialog.component'; + +import { ProductNotificationEditComponent } from './product-notification-edit.component'; + +describe('Product Notification Edit Component', () => { + let component: ProductNotificationEditComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let context: ProductContextFacade; + let accountFacade: AccountFacade; + + beforeEach(async () => { + context = mock(ProductContextFacade); + accountFacade = mock(accountFacade); + + await TestBed.configureTestingModule({ + declarations: [ + MockComponent(FaIconComponent), + MockComponent(ProductNotificationEditDialogComponent), + ProductNotificationEditComponent, + ], + providers: [ + { provide: AccountFacade, useFactory: () => instance(accountFacade) }, + { provide: ProductContextFacade, useFactory: () => instance(context) }, + ], + imports: [TranslateModule.forRoot()], + }).compileComponents(); + + when(accountFacade.isLoggedIn$).thenReturn(of(true)); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ProductNotificationEditComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should not show button when product is master or retail set', () => { + when(context.select('displayProperties', 'addToNotification')).thenReturn(of(false)); + expect(element.querySelector('button')).toBeFalsy(); + }); + + it('should include the custom notification modal dialog', () => { + fixture.detectChanges(); + expect(element.querySelector('ish-product-notification-edit-dialog')).toBeTruthy(); + }); + + it('should not show an icon when display type is not icon', () => { + fixture.detectChanges(); + expect(element.querySelector('fa-icon')).toBeFalsy(); + }); + + it('should show icon button when display type is icon', () => { + when(context.select('displayProperties', 'addToNotification')).thenReturn(of(true)); + component.displayType = 'icon'; + fixture.detectChanges(); + expect(element.querySelector('fa-icon')).toBeTruthy(); + }); + + it('should display price notification localization button text if the product is available', () => { + when(context.select('product', 'available')).thenReturn(of(true)); + when(context.select('displayProperties', 'addToNotification')).thenReturn(of(true)); + component.displayType = 'button'; + fixture.detectChanges(); + expect(element.textContent).toContain('product.notification.price.notification.button.label'); + }); + + it('should display stock notification localization button text if the product is not available', () => { + when(context.select('product', 'available')).thenReturn(of(false)); + when(context.select('displayProperties', 'addToNotification')).thenReturn(of(true)); + component.displayType = 'button'; + fixture.detectChanges(); + expect(element.textContent).toContain('product.notification.stock.notification.button.label'); + }); +}); diff --git a/src/app/extensions/product-notifications/shared/product-notification-edit/product-notification-edit.component.ts b/src/app/extensions/product-notifications/shared/product-notification-edit/product-notification-edit.component.ts new file mode 100644 index 0000000000..47bb4d0c01 --- /dev/null +++ b/src/app/extensions/product-notifications/shared/product-notification-edit/product-notification-edit.component.ts @@ -0,0 +1,68 @@ +import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { Observable, Subject } from 'rxjs'; +import { map, take, takeUntil } from 'rxjs/operators'; + +import { AccountFacade } from 'ish-core/facades/account.facade'; +import { ProductContextFacade } from 'ish-core/facades/product-context.facade'; +import { GenerateLazyComponent } from 'ish-core/utils/module-loader/generate-lazy-component.decorator'; + +import { ProductNotification } from '../../models/product-notification/product-notification.model'; +import { ProductNotificationEditDialogComponent } from '../product-notification-edit-dialog/product-notification-edit-dialog.component'; + +/** + * The Product Notification Edit Component shows the customer a link to open the dialog to either create, + * edit or delete a product notification. + * + * @example + * + */ +@Component({ + selector: 'ish-product-notification-edit', + templateUrl: './product-notification-edit.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +@GenerateLazyComponent() +export class ProductNotificationEditComponent implements OnDestroy, OnInit { + @Input() productNotification: ProductNotification; + @Input() displayType: 'button' | 'icon' = 'button'; + @Input() cssClass: string; + + visible$: Observable; + + private destroy$ = new Subject(); + + constructor(private context: ProductContextFacade, private accountFacade: AccountFacade, private router: Router) {} + + ngOnInit() { + this.visible$ = this.context.select('displayProperties', 'addToNotification'); + } + + // keep-localization-pattern: ^product\.notification\..*\.notification\.button.* + buttonKey(key: string): Observable { + return this.context + .select('product', 'available') + .pipe(map(available => `product.notification.${available ? 'price' : 'stock'}.notification.button.${key}`)); + } + + // if the user is not logged in display login dialog, else open notification dialog + openModal(modal: ProductNotificationEditDialogComponent) { + this.accountFacade.isLoggedIn$.pipe(take(1), takeUntil(this.destroy$)).subscribe(isLoggedIn => { + if (isLoggedIn) { + modal.show(); + } else { + // stay on the same page after login + const queryParams = { returnUrl: this.router.routerState.snapshot.url, messageKey: 'productnotification' }; + this.router.navigate(['/login'], { queryParams }); + } + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/src/app/extensions/product-notifications/store/product-notification/index.ts b/src/app/extensions/product-notifications/store/product-notification/index.ts new file mode 100644 index 0000000000..a32456a548 --- /dev/null +++ b/src/app/extensions/product-notifications/store/product-notification/index.ts @@ -0,0 +1,3 @@ +// API to access ngrx productNotification state +export * from './product-notification.actions'; +export * from './product-notification.selectors'; diff --git a/src/app/extensions/product-notifications/store/product-notification/product-notification.actions.ts b/src/app/extensions/product-notifications/store/product-notification/product-notification.actions.ts new file mode 100644 index 0000000000..2a988e7734 --- /dev/null +++ b/src/app/extensions/product-notifications/store/product-notification/product-notification.actions.ts @@ -0,0 +1,39 @@ +import { createActionGroup } from '@ngrx/store'; + +import { httpError, payload } from 'ish-core/utils/ngrx-creators'; + +import { + ProductNotification, + ProductNotificationType, +} from '../../models/product-notification/product-notification.model'; + +export const productNotificationsActions = createActionGroup({ + source: 'Product Notifications', + events: { + 'Load Product Notifications': payload<{ type: ProductNotificationType }>(), + 'Create Product Notification': payload<{ productNotification: ProductNotification }>(), + 'Update Product Notification': payload<{ sku: string; productNotification: ProductNotification }>(), + 'Delete Product Notification': payload<{ + sku: string; + productNotificationType: ProductNotificationType; + productNotificationId: string; + }>(), + }, +}); + +export const productNotificationsApiActions = createActionGroup({ + source: 'Product Notification API', + events: { + 'Load Product Notifications Success': payload<{ + productNotifications: ProductNotification[]; + type: ProductNotificationType; + }>(), + 'Load Product Notifications Fail': httpError<{}>(), + 'Create Product Notification Success': payload<{ productNotification: ProductNotification }>(), + 'Create Product Notification Fail': httpError<{}>(), + 'Update Product Notification Success': payload<{ productNotification: ProductNotification }>(), + 'Update Product Notification Fail': httpError<{}>(), + 'Delete Product Notification Success': payload<{ productNotificationId: string }>(), + 'Delete Product Notification Fail': httpError<{}>(), + }, +}); diff --git a/src/app/extensions/product-notifications/store/product-notification/product-notification.effects.spec.ts b/src/app/extensions/product-notifications/store/product-notification/product-notification.effects.spec.ts new file mode 100644 index 0000000000..4519bb0f92 --- /dev/null +++ b/src/app/extensions/product-notifications/store/product-notification/product-notification.effects.spec.ts @@ -0,0 +1,290 @@ +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Action } from '@ngrx/store'; +import { cold, hot } from 'jasmine-marbles'; +import { Observable, of, throwError } from 'rxjs'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; + +import { displayErrorMessage, displaySuccessMessage } from 'ish-core/store/core/messages'; +import { makeHttpError } from 'ish-core/utils/dev/api-service-utils'; + +import { + ProductNotification, + ProductNotificationType, +} from '../../models/product-notification/product-notification.model'; +import { ProductNotificationsService } from '../../services/product-notifications/product-notifications.service'; + +import { productNotificationsActions, productNotificationsApiActions } from './product-notification.actions'; +import { ProductNotificationEffects } from './product-notification.effects'; + +describe('Product Notification Effects', () => { + let actions$: Observable; + let effects: ProductNotificationEffects; + let productNotificationsServiceMock: ProductNotificationsService; + + const productNotifications: ProductNotification[] = [ + { + id: '12345_price', + type: 'price', + sku: '12345', + notificationMailAddress: 'test@test.intershop.de', + price: { type: 'Money', value: 75, currency: 'USD' }, + }, + { + id: '67890_price', + type: 'price', + sku: '67890', + notificationMailAddress: 'test@test.intershop.de', + price: { type: 'Money', value: 15, currency: 'USD' }, + }, + ]; + + const type: ProductNotificationType = 'price'; + const sku = '12345'; + const productNotificationId = '12345_price'; + + beforeEach(() => { + productNotificationsServiceMock = mock(ProductNotificationsService); + when(productNotificationsServiceMock.getProductNotifications('price')).thenReturn(of(productNotifications)); + when(productNotificationsServiceMock.createProductNotification(anything())).thenReturn(of(productNotifications[0])); + when(productNotificationsServiceMock.updateProductNotification(anything(), anything())).thenReturn( + of(productNotifications[0]) + ); + when(productNotificationsServiceMock.deleteProductNotification(anything(), anything())).thenReturn(of({})); + + TestBed.configureTestingModule({ + providers: [ + { provide: ProductNotificationsService, useFactory: () => instance(productNotificationsServiceMock) }, + ProductNotificationEffects, + provideMockActions(() => actions$), + ], + }); + + effects = TestBed.inject(ProductNotificationEffects); + }); + + describe('loadProductNotification$', () => { + it('should call the service for retrieving product notifications', done => { + actions$ = of(productNotificationsActions.loadProductNotifications({ type: 'price' })); + effects.loadProductNotifications$.subscribe(() => { + verify(productNotificationsServiceMock.getProductNotifications('price')).once(); + done(); + }); + + actions$ = of(productNotificationsActions.loadProductNotifications({ type: 'stock' })); + effects.loadProductNotifications$.subscribe(() => { + verify(productNotificationsServiceMock.getProductNotifications('stock')).once(); + done(); + }); + }); + + it('should map to actions of type LoadProductNotificationsSuccess', () => { + const action = productNotificationsActions.loadProductNotifications({ type: 'price' }); + const completion = productNotificationsApiActions.loadProductNotificationsSuccess({ + productNotifications, + type, + }); + + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.loadProductNotifications$).toBeObservable(expected$); + }); + + it('should map invalid request to action of type loadProductNotificationsFail', () => { + when(productNotificationsServiceMock.getProductNotifications(anything())).thenReturn( + throwError(() => makeHttpError({ message: 'invalid' })) + ); + const action = productNotificationsActions.loadProductNotifications({ type: 'price' }); + const completion = productNotificationsApiActions.loadProductNotificationsFail({ + error: makeHttpError({ message: 'invalid' }), + }); + + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.loadProductNotifications$).toBeObservable(expected$); + }); + }); + + describe('createProductNotification$', () => { + it('should call the service when createProductNotification event is called', done => { + const action = productNotificationsActions.createProductNotification({ + productNotification: productNotifications[0], + }); + actions$ = of(action); + + effects.createProductNotification$.subscribe(() => { + verify(productNotificationsServiceMock.createProductNotification(productNotifications[0])).once(); + done(); + }); + }); + + it('should map to actions of type createProductNotificationSuccess and displaySuccessMessage', () => { + const action = productNotificationsActions.createProductNotification({ + productNotification: productNotifications[0], + }); + const completion1 = productNotificationsApiActions.createProductNotificationSuccess({ + productNotification: productNotifications[0], + }); + + const completion2 = displaySuccessMessage({ + message: 'product.notification.create.success.message', + }); + + actions$ = hot('-a', { a: action }); + const expected$ = cold('-(cd)', { c: completion1, d: completion2 }); + + expect(effects.createProductNotification$).toBeObservable(expected$); + }); + + it('should dispatch a createProductNotificationFail action on failed', () => { + const error = makeHttpError({ status: 401, code: 'error' }); + when(productNotificationsServiceMock.createProductNotification(productNotifications[0])).thenReturn( + throwError(() => error) + ); + + const action = productNotificationsActions.createProductNotification({ + productNotification: productNotifications[0], + }); + const completion = productNotificationsApiActions.createProductNotificationFail({ + error, + }); + + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.createProductNotification$).toBeObservable(expected$); + }); + }); + + describe('updateProductNotification$', () => { + it('should call the service when updateProductNotification event is called', done => { + const action = productNotificationsActions.updateProductNotification({ + sku, + productNotification: productNotifications[0], + }); + actions$ = of(action); + + effects.updateProductNotification$.subscribe(() => { + verify(productNotificationsServiceMock.updateProductNotification(sku, productNotifications[0])).once(); + done(); + }); + }); + + it('should dispatch a updateProductNotification action on successful', () => { + const action = productNotificationsActions.updateProductNotification({ + sku, + productNotification: productNotifications[0], + }); + const completion1 = productNotificationsApiActions.updateProductNotificationSuccess({ + productNotification: productNotifications[0], + }); + + const completion2 = displaySuccessMessage({ + message: 'product.notification.update.success.message', + }); + + actions$ = hot('-a', { a: action }); + const expected$ = cold('-(cd)', { c: completion1, d: completion2 }); + + expect(effects.updateProductNotification$).toBeObservable(expected$); + }); + + it('should dispatch a updateProductNotificationFail action on failed', () => { + const error = makeHttpError({ status: 401, code: 'error' }); + when(productNotificationsServiceMock.updateProductNotification(sku, productNotifications[0])).thenReturn( + throwError(() => error) + ); + + const action = productNotificationsActions.updateProductNotification({ + sku, + productNotification: productNotifications[0], + }); + const completion = productNotificationsApiActions.updateProductNotificationFail({ + error, + }); + + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.updateProductNotification$).toBeObservable(expected$); + }); + }); + + describe('deleteProductNotification$', () => { + it('should call the service when deleteProductNotification event is called', done => { + const productNotificationType: ProductNotificationType = 'price'; + + const action = productNotificationsActions.deleteProductNotification({ + sku, + productNotificationType, + productNotificationId, + }); + actions$ = of(action); + + effects.deleteProductNotification$.subscribe(() => { + verify(productNotificationsServiceMock.deleteProductNotification(sku, type)).once(); + done(); + }); + }); + + it('should dispatch a deleteProductNotification action on successful', () => { + const productNotificationType: ProductNotificationType = 'price'; + + const action = productNotificationsActions.deleteProductNotification({ + sku, + productNotificationType, + productNotificationId, + }); + const completion1 = productNotificationsApiActions.deleteProductNotificationSuccess({ productNotificationId }); + + const completion2 = displaySuccessMessage({ + message: 'product.notification.delete.success.message', + }); + + actions$ = hot('-a', { a: action }); + const expected$ = cold('-(cd)', { c: completion1, d: completion2 }); + + expect(effects.deleteProductNotification$).toBeObservable(expected$); + }); + + it('should dispatch a deleteProductNotificationFail action on failed', () => { + const productNotificationType: ProductNotificationType = 'price'; + const error = makeHttpError({ status: 401, code: 'error' }); + when(productNotificationsServiceMock.deleteProductNotification(sku, type)).thenReturn(throwError(() => error)); + + const action = productNotificationsActions.deleteProductNotification({ + sku, + productNotificationType, + productNotificationId, + }); + const completion = productNotificationsApiActions.deleteProductNotificationFail({ + error, + }); + + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.deleteProductNotification$).toBeObservable(expected$); + }); + }); + + describe('displayProductNotificationErrorMessage$', () => { + it('should map to action of type displayProductNotificationErrorMessage in case of an error', () => { + const error = makeHttpError({ status: 401, code: 'feld', message: 'e-message' }); + + const action = productNotificationsApiActions.loadProductNotificationsFail({ + error, + }); + const completion = displayErrorMessage({ + message: 'e-message', + }); + + actions$ = hot('-a', { a: action }); + const expected$ = cold('-b', { b: completion }); + + expect(effects.displayProductNotificationErrorMessage$).toBeObservable(expected$); + }); + }); +}); diff --git a/src/app/extensions/product-notifications/store/product-notification/product-notification.effects.ts b/src/app/extensions/product-notifications/store/product-notification/product-notification.effects.ts new file mode 100644 index 0000000000..f38b7516ed --- /dev/null +++ b/src/app/extensions/product-notifications/store/product-notification/product-notification.effects.ts @@ -0,0 +1,105 @@ +import { Injectable } from '@angular/core'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { concatMap, map, mergeMap, switchMap } from 'rxjs'; + +import { displayErrorMessage, displaySuccessMessage } from 'ish-core/store/core/messages'; +import { mapErrorToAction, mapToPayload, mapToPayloadProperty, whenTruthy } from 'ish-core/utils/operators'; + +import { ProductNotificationsService } from '../../services/product-notifications/product-notifications.service'; + +import { productNotificationsActions, productNotificationsApiActions } from './product-notification.actions'; + +@Injectable() +export class ProductNotificationEffects { + constructor(private actions$: Actions, private productNotificationsService: ProductNotificationsService) {} + + loadProductNotifications$ = createEffect(() => + this.actions$.pipe( + ofType(productNotificationsActions.loadProductNotifications), + mapToPayloadProperty('type'), + switchMap(type => + this.productNotificationsService.getProductNotifications(type).pipe( + map(productNotifications => + productNotificationsApiActions.loadProductNotificationsSuccess({ productNotifications, type }) + ), + mapErrorToAction(productNotificationsApiActions.loadProductNotificationsFail) + ) + ) + ) + ); + + createProductNotification$ = createEffect(() => + this.actions$.pipe( + ofType(productNotificationsActions.createProductNotification), + mapToPayload(), + whenTruthy(), + mergeMap(payload => + this.productNotificationsService.createProductNotification(payload.productNotification).pipe( + mergeMap(productNotification => [ + productNotificationsApiActions.createProductNotificationSuccess({ productNotification }), + displaySuccessMessage({ + message: 'product.notification.create.success.message', + }), + ]), + mapErrorToAction(productNotificationsApiActions.createProductNotificationFail) + ) + ) + ) + ); + + updateProductNotification$ = createEffect(() => + this.actions$.pipe( + ofType(productNotificationsActions.updateProductNotification), + mapToPayload(), + whenTruthy(), + concatMap(payload => + this.productNotificationsService.updateProductNotification(payload.sku, payload.productNotification).pipe( + mergeMap(productNotification => [ + productNotificationsApiActions.updateProductNotificationSuccess({ productNotification }), + displaySuccessMessage({ + message: 'product.notification.update.success.message', + }), + ]), + mapErrorToAction(productNotificationsApiActions.updateProductNotificationFail) + ) + ) + ) + ); + + deleteProductNotification$ = createEffect(() => + this.actions$.pipe( + ofType(productNotificationsActions.deleteProductNotification), + mapToPayload(), + mergeMap(payload => + this.productNotificationsService.deleteProductNotification(payload.sku, payload.productNotificationType).pipe( + mergeMap(() => [ + productNotificationsApiActions.deleteProductNotificationSuccess({ + productNotificationId: payload.productNotificationId, + }), + displaySuccessMessage({ + message: 'product.notification.delete.success.message', + }), + ]), + mapErrorToAction(productNotificationsApiActions.deleteProductNotificationFail) + ) + ) + ) + ); + + displayProductNotificationErrorMessage$ = createEffect(() => + this.actions$.pipe( + ofType( + productNotificationsApiActions.loadProductNotificationsFail, + productNotificationsApiActions.createProductNotificationFail, + productNotificationsApiActions.updateProductNotificationFail, + productNotificationsApiActions.deleteProductNotificationFail + ), + mapToPayloadProperty('error'), + map(error => + displayErrorMessage({ + message: error.message, + }) + ) + ) + ); +} diff --git a/src/app/extensions/product-notifications/store/product-notification/product-notification.reducer.ts b/src/app/extensions/product-notifications/store/product-notification/product-notification.reducer.ts new file mode 100644 index 0000000000..6a9af1a517 --- /dev/null +++ b/src/app/extensions/product-notifications/store/product-notification/product-notification.reducer.ts @@ -0,0 +1,68 @@ +import { EntityState, createEntityAdapter } from '@ngrx/entity'; +import { createReducer, on } from '@ngrx/store'; + +import { HttpError } from 'ish-core/models/http-error/http-error.model'; +import { setErrorOn, setLoadingOn, unsetLoadingAndErrorOn } from 'ish-core/utils/ngrx-creators'; + +import { ProductNotification } from '../../models/product-notification/product-notification.model'; + +import { productNotificationsActions, productNotificationsApiActions } from './product-notification.actions'; + +export const productNotificationAdapter = createEntityAdapter(); + +export interface ProductNotificationState extends EntityState { + loading: boolean; + error: HttpError; +} + +export const initialState: ProductNotificationState = productNotificationAdapter.getInitialState({ + loading: false, + error: undefined, +}); + +export const productNotificationReducer = createReducer( + initialState, + setLoadingOn( + productNotificationsActions.loadProductNotifications, + productNotificationsActions.createProductNotification, + productNotificationsActions.updateProductNotification, + productNotificationsActions.deleteProductNotification + ), + setErrorOn( + productNotificationsApiActions.loadProductNotificationsFail, + productNotificationsApiActions.createProductNotificationFail, + productNotificationsApiActions.updateProductNotificationFail, + productNotificationsApiActions.deleteProductNotificationFail + ), + unsetLoadingAndErrorOn( + productNotificationsApiActions.loadProductNotificationsSuccess, + productNotificationsApiActions.createProductNotificationSuccess, + productNotificationsApiActions.updateProductNotificationSuccess, + productNotificationsApiActions.deleteProductNotificationSuccess + ), + on(productNotificationsApiActions.loadProductNotificationsSuccess, (state, action) => + /** + * Product notifications can be deleted on server side when the notification requirements + * are met and the notification email was sent. Therefore, all product notifications + * have to be removed from the state which are not returned from the service before they + * are loaded, displayed or used. If setAll would be used, the list of notifications would + * always be empty at first and only filled when the REST request has finished. + */ + productNotificationAdapter.upsertMany(action.payload.productNotifications, { + ...productNotificationAdapter.removeMany(entity => entity.type === action.payload.type, state), + }) + ), + on(productNotificationsApiActions.createProductNotificationSuccess, (state, action) => + productNotificationAdapter.addOne(action.payload.productNotification, state) + ), + on(productNotificationsApiActions.updateProductNotificationSuccess, (state, action) => + productNotificationAdapter.upsertOne(action.payload.productNotification, state) + ), + on(productNotificationsApiActions.deleteProductNotificationSuccess, (state, action) => { + const id = action.payload.productNotificationId; + + return { + ...productNotificationAdapter.removeOne(id, state), + }; + }) +); diff --git a/src/app/extensions/product-notifications/store/product-notification/product-notification.selectors.spec.ts b/src/app/extensions/product-notifications/store/product-notification/product-notification.selectors.spec.ts new file mode 100644 index 0000000000..fa10d812d5 --- /dev/null +++ b/src/app/extensions/product-notifications/store/product-notification/product-notification.selectors.spec.ts @@ -0,0 +1,168 @@ +import { TestBed } from '@angular/core/testing'; + +import { CoreStoreModule } from 'ish-core/store/core/core-store.module'; +import { makeHttpError } from 'ish-core/utils/dev/api-service-utils'; +import { StoreWithSnapshots, provideStoreSnapshots } from 'ish-core/utils/dev/ngrx-testing'; + +import { + ProductNotification, + ProductNotificationType, +} from '../../models/product-notification/product-notification.model'; +import { ProductNotificationsStoreModule } from '../product-notifications-store.module'; + +import { productNotificationsActions, productNotificationsApiActions } from './product-notification.actions'; +import { + getProductNotificationBySku, + getProductNotificationsByType, + getProductNotificationsError, + getProductNotificationsLoading, + selectAll, +} from './product-notification.selectors'; + +describe('Product Notification Selectors', () => { + let store$: StoreWithSnapshots; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CoreStoreModule.forTesting(), ProductNotificationsStoreModule.forTesting('productNotifications')], + providers: [provideStoreSnapshots()], + }); + + store$ = TestBed.inject(StoreWithSnapshots); + }); + + describe('initial state', () => { + it('should not be loading when in initial state', () => { + expect(getProductNotificationsLoading(store$.state)).toBeFalse(); + }); + + it('should not have an error when in initial state', () => { + expect(getProductNotificationsError(store$.state)).toBeUndefined(); + }); + + it('should not have entities when in initial state', () => { + expect(selectAll(store$.state)).toBeEmpty(); + }); + }); + + describe('LoadProductNotifications', () => { + const action = productNotificationsActions.loadProductNotifications({ type: 'price' }); + + beforeEach(() => { + store$.dispatch(action); + }); + + it('should set loading to true', () => { + expect(getProductNotificationsLoading(store$.state)).toBeTrue(); + }); + }); + + describe('productNotificationsActions.loadProductNotificationsSuccess', () => { + const productNotifications = [ + { + id: '1_price', + type: 'price', + sku: '1', + notificationMailAddress: 'a.b@c.de', + }, + { + id: '2_stock', + type: 'stock', + sku: '2', + notificationMailAddress: 'a.b@c.de', + }, + ] as ProductNotification[]; + + const type: ProductNotificationType = 'price'; + + beforeEach(() => { + store$.dispatch(productNotificationsApiActions.loadProductNotificationsSuccess({ productNotifications, type })); + }); + + it('should set loading to false', () => { + expect(getProductNotificationsLoading(store$.state)).toBeFalse(); + }); + + it('should not have an error when successfully loaded entities', () => { + expect(getProductNotificationsError(store$.state)).toBeUndefined(); + }); + + it('should have entities when successfully loading', () => { + expect(selectAll(store$.state)).not.toBeEmpty(); + }); + }); + + describe('productNotificationsActions.loadProductNotificationsFail', () => { + beforeEach(() => { + store$.dispatch( + productNotificationsApiActions.loadProductNotificationsFail({ error: makeHttpError({ message: 'error' }) }) + ); + }); + + it('should set loading to false', () => { + expect(getProductNotificationsLoading(store$.state)).toBeFalse(); + }); + + it('should have an error when reducing', () => { + expect(getProductNotificationsError(store$.state)).toBeTruthy(); + }); + }); + + describe('getProductNotificationsByType', () => { + const productNotifications = [ + { + id: '1_price', + type: 'price', + sku: '1', + notificationMailAddress: 'a.b@c.de', + }, + ] as ProductNotification[]; + + const type: ProductNotificationType = 'price'; + + beforeEach(() => { + store$.dispatch(productNotificationsApiActions.loadProductNotificationsSuccess({ productNotifications, type })); + }); + + it('should set loading to false', () => { + expect(getProductNotificationsLoading(store$.state)).toBeFalse(); + }); + + it('should not have an error when successfully loaded entities', () => { + expect(getProductNotificationsError(store$.state)).toBeUndefined(); + }); + + it('should return correct product notification of type price', () => { + expect(getProductNotificationsByType(type)(store$.state)).toEqual(productNotifications); + }); + }); + + describe('getProductNotificationBySku', () => { + const productNotifications = [ + { + id: '1_price', + type: 'price', + sku: '1', + notificationMailAddress: 'a.b@c.de', + }, + ] as ProductNotification[]; + + const type: ProductNotificationType = 'price'; + + beforeEach(() => { + store$.dispatch(productNotificationsApiActions.loadProductNotificationsSuccess({ productNotifications, type })); + }); + + it('should set loading to false', () => { + expect(getProductNotificationsLoading(store$.state)).toBeFalse(); + }); + + it('should not have an error when successfully loaded entities', () => { + expect(getProductNotificationsError(store$.state)).toBeUndefined(); + }); + + it('should return correct product notification with specific sku', () => { + expect(getProductNotificationBySku('1', 'price')(store$.state)).toEqual(productNotifications); + }); + }); +}); diff --git a/src/app/extensions/product-notifications/store/product-notification/product-notification.selectors.ts b/src/app/extensions/product-notifications/store/product-notification/product-notification.selectors.ts new file mode 100644 index 0000000000..f67d64b043 --- /dev/null +++ b/src/app/extensions/product-notifications/store/product-notification/product-notification.selectors.ts @@ -0,0 +1,27 @@ +import { createSelector } from '@ngrx/store'; + +import { + ProductNotification, + ProductNotificationType, +} from '../../models/product-notification/product-notification.model'; +import { getProductNotificationsState } from '../product-notifications-store'; + +import { initialState, productNotificationAdapter } from './product-notification.reducer'; + +const getProductNotificationState = createSelector(getProductNotificationsState, state => + state ? state.productNotifications : initialState +); + +export const getProductNotificationsLoading = createSelector(getProductNotificationState, state => state.loading); + +export const getProductNotificationsError = createSelector(getProductNotificationState, state => state.error); + +export const { selectAll } = productNotificationAdapter.getSelectors(getProductNotificationState); + +export const getProductNotificationsByType = (type: ProductNotificationType) => + createSelector(selectAll, (entities): ProductNotification[] => entities.filter(e => e.type === type)); + +export const getProductNotificationBySku = (sku: string, type: ProductNotificationType) => + createSelector(selectAll, (entities): ProductNotification[] => + entities.filter(e => e.sku === sku && e.type === type) + ); diff --git a/src/app/extensions/product-notifications/store/product-notifications-store.module.ts b/src/app/extensions/product-notifications/store/product-notifications-store.module.ts new file mode 100644 index 0000000000..ccfea604ff --- /dev/null +++ b/src/app/extensions/product-notifications/store/product-notifications-store.module.ts @@ -0,0 +1,38 @@ +import { Injectable, InjectionToken, NgModule } from '@angular/core'; +import { EffectsModule } from '@ngrx/effects'; +import { ActionReducerMap, StoreConfig, StoreModule } from '@ngrx/store'; +import { pick } from 'lodash-es'; + +import { resetOnLogoutMeta } from 'ish-core/utils/meta-reducers'; + +import { ProductNotificationEffects } from './product-notification/product-notification.effects'; +import { productNotificationReducer } from './product-notification/product-notification.reducer'; +import { ProductNotificationsState } from './product-notifications-store'; + +const productNotificationsReducers: ActionReducerMap = { + productNotifications: productNotificationReducer, +}; + +const productNotificationsEffects = [ProductNotificationEffects]; + +@Injectable() +export class ProductNotificationsConfig implements StoreConfig { + metaReducers = [resetOnLogoutMeta]; +} + +export const PRODUCT_NOTIFICATIONS_STORE_CONFIG = new InjectionToken>( + 'productNotificationsStoreConfig' +); + +@NgModule({ + imports: [ + EffectsModule.forFeature(productNotificationsEffects), + StoreModule.forFeature('productNotifications', productNotificationsReducers, PRODUCT_NOTIFICATIONS_STORE_CONFIG), + ], + providers: [{ provide: PRODUCT_NOTIFICATIONS_STORE_CONFIG, useClass: ProductNotificationsConfig }], +}) +export class ProductNotificationsStoreModule { + static forTesting(...reducers: (keyof ActionReducerMap)[]) { + return StoreModule.forFeature('productNotifications', pick(productNotificationsReducers, reducers)); + } +} diff --git a/src/app/extensions/product-notifications/store/product-notifications-store.ts b/src/app/extensions/product-notifications/store/product-notifications-store.ts new file mode 100644 index 0000000000..99abfd628e --- /dev/null +++ b/src/app/extensions/product-notifications/store/product-notifications-store.ts @@ -0,0 +1,9 @@ +import { createFeatureSelector } from '@ngrx/store'; + +import { ProductNotificationState } from './product-notification/product-notification.reducer'; + +export interface ProductNotificationsState { + productNotifications: ProductNotificationState; +} + +export const getProductNotificationsState = createFeatureSelector('productNotifications'); diff --git a/src/app/extensions/punchout/exports/punchout-product-context-display-properties/punchout-product-context-display-properties.service.ts b/src/app/extensions/punchout/exports/punchout-product-context-display-properties/punchout-product-context-display-properties.service.ts index 47e8c296cd..d0ce1ad3a0 100644 --- a/src/app/extensions/punchout/exports/punchout-product-context-display-properties/punchout-product-context-display-properties.service.ts +++ b/src/app/extensions/punchout/exports/punchout-product-context-display-properties/punchout-product-context-display-properties.service.ts @@ -20,6 +20,7 @@ export class PunchoutProductContextDisplayPropertiesService implements ExternalD isPunchoutUser ? { addToQuote: false, + addToNotification: false, shipment: false, } : {} diff --git a/src/app/extensions/tacton/exports/tacton-product-context-display-properties/tacton-product-context-display-properties.service.ts b/src/app/extensions/tacton/exports/tacton-product-context-display-properties/tacton-product-context-display-properties.service.ts index 633c574988..84af8a3a6b 100644 --- a/src/app/extensions/tacton/exports/tacton-product-context-display-properties/tacton-product-context-display-properties.service.ts +++ b/src/app/extensions/tacton/exports/tacton-product-context-display-properties/tacton-product-context-display-properties.service.ts @@ -17,6 +17,7 @@ export class TactonProductContextDisplayPropertiesService implements ExternalDis ? { addToBasket: false, addToCompare: false, + addToNotification: false, addToOrderTemplate: false, addToQuote: false, addToWishlist: false, diff --git a/src/app/pages/account/account-navigation/account-navigation.items.b2c.ts b/src/app/pages/account/account-navigation/account-navigation.items.b2c.ts index cbbbaac4c4..9f2effa54b 100644 --- a/src/app/pages/account/account-navigation/account-navigation.items.b2c.ts +++ b/src/app/pages/account/account-navigation/account-navigation.items.b2c.ts @@ -16,6 +16,13 @@ export const navigationItems: NavigationItem[] = [ feature: 'wishlists', notRole: ['APP_B2B_CXML_USER', 'APP_B2B_OCI_USER'], }, + { + id: 'notifications', + localizationKey: 'account.notifications.link', + routerLink: '/account/notifications', + feature: 'productNotifications', + notRole: ['APP_B2B_CXML_USER', 'APP_B2B_OCI_USER'], + }, { id: 'addresses', localizationKey: 'account.saved_addresses.link', diff --git a/src/app/pages/account/account-navigation/account-navigation.items.ts b/src/app/pages/account/account-navigation/account-navigation.items.ts index 87a66fe2b0..08a5184384 100644 --- a/src/app/pages/account/account-navigation/account-navigation.items.ts +++ b/src/app/pages/account/account-navigation/account-navigation.items.ts @@ -114,6 +114,13 @@ export const navigationItems: NavigationItem[] = [ routerLink: '/account/payment', notRole: ['APP_B2B_CXML_USER', 'APP_B2B_OCI_USER'], }, + { + id: 'notifications', + localizationKey: 'account.notifications.link', + routerLink: '/account/notifications', + feature: 'productNotifications', + notRole: ['APP_B2B_CXML_USER', 'APP_B2B_OCI_USER'], + }, ], }, { diff --git a/src/app/pages/account/account-page.module.ts b/src/app/pages/account/account-page.module.ts index 9d9fb8405c..c5b545a5a2 100644 --- a/src/app/pages/account/account-page.module.ts +++ b/src/app/pages/account/account-page.module.ts @@ -80,6 +80,14 @@ const accountPageRoutes: Routes = [ loadChildren: () => import('../../extensions/wishlists/pages/wishlists-routing.module').then(m => m.WishlistsRoutingModule), }, + { + path: 'notifications', + data: { breadcrumbData: [{ key: 'account.notifications.breadcrumb_link' }] }, + loadChildren: () => + import('../../extensions/product-notifications/pages/product-notifications-routing.module').then( + m => m.ProductNotificationsRoutingModule + ), + }, { path: 'organization', canActivate: [authorizationToggleGuard], diff --git a/src/app/pages/product/product-detail/product-detail.component.html b/src/app/pages/product/product-detail/product-detail.component.html index 2148410cb0..25f59b1a19 100644 --- a/src/app/pages/product/product-detail/product-detail.component.html +++ b/src/app/pages/product/product-detail/product-detail.component.html @@ -44,6 +44,11 @@

> + +
diff --git a/src/app/pages/product/product-detail/product-detail.component.spec.ts b/src/app/pages/product/product-detail/product-detail.component.spec.ts index c439fb4982..f03c69be16 100644 --- a/src/app/pages/product/product-detail/product-detail.component.spec.ts +++ b/src/app/pages/product/product-detail/product-detail.component.spec.ts @@ -17,6 +17,7 @@ import { ProductQuantityComponent } from 'ish-shared/components/product/product- import { ProductShipmentComponent } from 'ish-shared/components/product/product-shipment/product-shipment.component'; import { LazyProductAddToOrderTemplateComponent } from '../../../extensions/order-templates/exports/lazy-product-add-to-order-template/lazy-product-add-to-order-template.component'; +import { LazyProductNotificationEditComponent } from '../../../extensions/product-notifications/exports/lazy-product-notification-edit/lazy-product-notification-edit.component'; import { LazyProductAddToQuoteComponent } from '../../../extensions/quoting/exports/lazy-product-add-to-quote/lazy-product-add-to-quote.component'; import { LazyProductRatingComponent } from '../../../extensions/rating/exports/lazy-product-rating/lazy-product-rating.component'; import { LazyTactonConfigureProductComponent } from '../../../extensions/tacton/exports/lazy-tacton-configure-product/lazy-tacton-configure-product.component'; @@ -41,6 +42,7 @@ describe('Product Detail Component', () => { MockComponent(ContentViewcontextComponent), MockComponent(LazyProductAddToOrderTemplateComponent), MockComponent(LazyProductAddToQuoteComponent), + MockComponent(LazyProductNotificationEditComponent), MockComponent(LazyProductRatingComponent), MockComponent(LazyTactonConfigureProductComponent), MockComponent(ProductAddToBasketComponent), @@ -95,6 +97,7 @@ describe('Product Detail Component', () => { "ish-product-add-to-basket", "ish-lazy-product-add-to-order-template", "ish-lazy-product-add-to-quote", + "ish-lazy-product-notification-edit", "ish-content-viewcontext", ] `); diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index 802e06bfca..91f661f216 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -26,6 +26,7 @@ import { CaptchaExportsModule } from '../extensions/captcha/exports/captcha-expo import { CompareExportsModule } from '../extensions/compare/exports/compare-exports.module'; import { ContactUsExportsModule } from '../extensions/contact-us/exports/contact-us-exports.module'; import { OrderTemplatesExportsModule } from '../extensions/order-templates/exports/order-templates-exports.module'; +import { ProductNotificationsExportsModule } from '../extensions/product-notifications/exports/product-notifications-exports.module'; import { PunchoutExportsModule } from '../extensions/punchout/exports/punchout-exports.module'; import { QuickorderExportsModule } from '../extensions/quickorder/exports/quickorder-exports.module'; import { QuotingExportsModule } from '../extensions/quoting/exports/quoting-exports.module'; @@ -167,6 +168,7 @@ const importExportModules = [ NgbPopoverModule, OrderTemplatesExportsModule, PipesModule, + ProductNotificationsExportsModule, PunchoutExportsModule, QuickorderExportsModule, QuotingExportsModule, diff --git a/src/assets/i18n/de_DE.json b/src/assets/i18n/de_DE.json index 28ec01e1cc..8b13e7b2a5 100644 --- a/src/assets/i18n/de_DE.json +++ b/src/assets/i18n/de_DE.json @@ -174,6 +174,7 @@ "account.login.ordertemplates.message": "Melden Sie sich mit Ihrem Benutzernamen an, um Bestellvorlagen zu benutzen.", "account.login.password.error.required": "Bitte geben Sie ein Kennwort ein.", "account.login.password.label": "Kennwort", + "account.login.productnotification.message": "Melden Sie sich mit Ihrem Benutzernamen an, um eine Benachrichtigung zu erstellen.", "account.login.profile_settings.message": "Melden Sie sich mit Ihrem Benutzernamen an, um Profileinstellungen zu aktualisieren.", "account.login.quotes.message": "Melden Sie sich mit Ihrem Benutzernamen an, um Preisangebote zu benutzen.", "account.login.register_now": "Sie haben noch kein Benutzerkonto?
Jetzt anmelden", @@ -195,6 +196,16 @@ "account.navigation.logout.link": "Abmelden", "account.navigation.quotes.link": "Preisangebote", "account.new_user.heading": "Neue Benutzer", + "account.notifications.backinstock.heading": "Wieder lieferbar", + "account.notifications.breadcrumb_link": "Benachrichtigungen", + "account.notifications.heading": "Produktbenachrichtigungen", + "account.notifications.link": "Benachrichtigungen", + "account.notifications.no_items_message": "Zurzeit sind keine Produkte zur Beobachtung ausgewählt.", + "account.notifications.price.heading": "Preisänderungen", + "account.notifications.price.text": "Benachrichtige {{0}}, wenn das Produkt folgenden Preis erreicht {{1}}.", + "account.notifications.stock.text": "Benachrichtige {{0}}, sobald das Produkt wieder auf Lager ist.", + "account.notifications.table.notification": "Benachrichtigung", + "account.notifications.table.product": "Produkt", "account.option.select.text": "Bitte auswählen", "account.order.most_recent.heading": "Letzte Bestellungen", "account.order.questions.note": "Besuchen Sie die Hilfe auf unserer Website für umfassende Bestell- und Versandinformationen oder kontaktieren Sie uns rund um die Uhr.", @@ -892,6 +903,28 @@ "product.label.sale.text": "SALE", "product.label.topseller.text": "TOP", "product.manufacturer_name.label": "Herstellername", + "product.notification.create.success.message": "Ihre Produktbenachrichtigung wurde angelegt.", + "product.notification.delete.button.label": "Löschen", + "product.notification.delete.cancel.button.label": "Abbrechen", + "product.notification.delete.link.title": "Benachrichtigung löschen", + "product.notification.delete.message": "

Sie sind im Begriff, eine Produktbenachrichtigung zu löschen.

Möchten Sie wirklich löschen?

", + "product.notification.delete.success.message": "Ihre Produktbenachrichtigung wurde gelöscht.", + "product.notification.edit.form.cancel.button.label": "Abbrechen", + "product.notification.edit.form.create.button.label": "OK", + "product.notification.edit.form.email.label": "E-Mail", + "product.notification.edit.form.instock_notification.label": "Benachrichtigen Sie mich, wenn das Produkt wieder auf Lager ist.", + "product.notification.edit.form.no_notification.label": "Ich möchte keine Benachrichtigungen mehr zu diesem Artikel erhalten.", + "product.notification.edit.form.price.error.required": "Geben Sie einen gültigen Geldbetrag ein.", + "product.notification.edit.form.price.error.valid": "Geben Sie einen gültigen Geldbetrag ein.", + "product.notification.edit.form.price.label": "Preis", + "product.notification.edit.form.price_notification.label": "Benachrichtigen Sie mich, falls das Produkt auf folgenden Preis sinkt:", + "product.notification.edit.form.update.button.label": "Aktualisieren", + "product.notification.edit.link.title": "Benachrichtigung bearbeiten", + "product.notification.price.notification.button.label": "Preisbenachrichtigung", + "product.notification.price.notification.button.title": "Benachrichtigung bearbeiten", + "product.notification.stock.notification.button.label": "Benachrichtigung, wenn es wieder verfügbar ist", + "product.notification.stock.notification.button.title": "Benachrichtigung bearbeiten", + "product.notification.update.success.message": "Ihre Produktbenachrichtigung wurde aktualisiert.", "product.out_of_stock.text": "Nicht verfügbar", "product.price.listPriceFallback.text": "{{0}}", "product.price.na.text": "k. A.", diff --git a/src/assets/i18n/en_US.json b/src/assets/i18n/en_US.json index 3350687396..d29b746841 100644 --- a/src/assets/i18n/en_US.json +++ b/src/assets/i18n/en_US.json @@ -174,6 +174,7 @@ "account.login.ordertemplates.message": "Please log in to your account to use order templates.", "account.login.password.error.required": "Please enter a password.", "account.login.password.label": "Password", + "account.login.productnotification.message": "Please log in to your account to create a notification.", "account.login.profile_settings.message": "Please log in to your account to update profile settings.", "account.login.quotes.message": "Please log in to your account to use quotes.", "account.login.register_now": "You don't have an account yet?
Register now", @@ -195,6 +196,16 @@ "account.navigation.logout.link": "Logout", "account.navigation.quotes.link": "Quoting", "account.new_user.heading": "New Users", + "account.notifications.backinstock.heading": "Back in Stock", + "account.notifications.breadcrumb_link": "Notifications", + "account.notifications.heading": "Product Notifications", + "account.notifications.link": "Notifications", + "account.notifications.no_items_message": "By now there are no products to be monitored.", + "account.notifications.price.heading": "Changes in Price", + "account.notifications.price.text": "Notify {{0}} when price reaches {{1}}.", + "account.notifications.stock.text": "Notify {{0}} when product is back in stock.", + "account.notifications.table.notification": "Notification", + "account.notifications.table.product": "Product", "account.option.select.text": "Please select", "account.order.most_recent.heading": "Most Recent Orders", "account.order.questions.note": "Please visit the Help area of our website for comprehensive order and shipping information or Contact Us 24 hours a day.", @@ -892,6 +903,28 @@ "product.label.sale.text": "SALE", "product.label.topseller.text": "TOP", "product.manufacturer_name.label": "Manufacturer Name", + "product.notification.create.success.message": "Your product notification has been created.", + "product.notification.delete.button.label": "Delete", + "product.notification.delete.cancel.button.label": "Cancel", + "product.notification.delete.link.title": "Delete notification", + "product.notification.delete.message": "

You are about to delete a product notification.

Are you sure you want to delete it?

", + "product.notification.delete.success.message": "Your product notification was deleted.", + "product.notification.edit.form.cancel.button.label": "Cancel", + "product.notification.edit.form.create.button.label": "OK", + "product.notification.edit.form.email.label": "E-mail", + "product.notification.edit.form.instock_notification.label": "Notify me when the product is back in stock.", + "product.notification.edit.form.no_notification.label": "I no longer wish to receive any notifications about this item.", + "product.notification.edit.form.price.error.required": "Please enter a valid amount of money.", + "product.notification.edit.form.price.error.valid": "Please enter a valid amount of money.", + "product.notification.edit.form.price.label": "Price", + "product.notification.edit.form.price_notification.label": "Notify me if the price drops to:", + "product.notification.edit.form.update.button.label": "Update", + "product.notification.edit.link.title": "Edit notification", + "product.notification.price.notification.button.label": "Price Notification", + "product.notification.price.notification.button.title": "Edit notification", + "product.notification.stock.notification.button.label": "Notify me when available", + "product.notification.stock.notification.button.title": "Edit notification", + "product.notification.update.success.message": "Your product notification was updated.", "product.out_of_stock.text": "Out of Stock", "product.price.listPriceFallback.text": "{{0}}", "product.price.na.text": "N/A", diff --git a/src/assets/i18n/fr_FR.json b/src/assets/i18n/fr_FR.json index c2e8076e3d..193b0e6e63 100644 --- a/src/assets/i18n/fr_FR.json +++ b/src/assets/i18n/fr_FR.json @@ -174,6 +174,7 @@ "account.login.ordertemplates.message": "Veuillez vous connecter à votre compte pour utiliser les modèles de commande.", "account.login.password.error.required": "Veuillez entrer un mot de passe.", "account.login.password.label": "Mot de passe", + "account.login.productnotification.message": "Veuillez vous connecter à votre compte pour créer une notification.", "account.login.profile_settings.message": "Veuillez vous connecter à votre compte pour mettre à jour les paramètres de profil.", "account.login.quotes.message": "Veuillez vous connecter à votre compte pour utiliser les devis.", "account.login.register_now": "Vous n’avez pas encore de compte??
Enregistrez-vous maintenant", @@ -195,6 +196,16 @@ "account.navigation.logout.link": "Déconnexion", "account.navigation.quotes.link": "Devis", "account.new_user.heading": "Nouveaux utilisateurs", + "account.notifications.backinstock.heading": "Retour en stock", + "account.notifications.breadcrumb_link": "Notifications", + "account.notifications.heading": "Notifications de produits", + "account.notifications.link": "Notifications", + "account.notifications.no_items_message": "A ce jour, il n’y a aucun produit à surveiller.", + "account.notifications.price.heading": "Changements de prix", + "account.notifications.price.text": "Prévenez {{0}} lorsque le prix atteint {{1}}.", + "account.notifications.stock.text": "Prévenez {{0}} lorsque le produit est de nouveau disponible.", + "account.notifications.table.notification": "Notification", + "account.notifications.table.product": "Produit", "account.option.select.text": "Veuillez sélectionner", "account.order.most_recent.heading": "Les commandes les plus récentes", "account.order.questions.note": "Merci de consulter la section Aide de notre site Web pour des renseignements détaillés sur votre commande et l’expédition ou Contactez-nous 24 heures sur 24.", @@ -892,6 +903,28 @@ "product.label.sale.text": "SOLDES", "product.label.topseller.text": "POPULAIRE", "product.manufacturer_name.label": "Nom du fabricant", + "product.notification.create.success.message": "Votre notification de produit a été créé.", + "product.notification.delete.button.label": "Supprimer", + "product.notification.delete.cancel.button.label": "Annuler", + "product.notification.delete.link.title": "Supprimer la notification", + "product.notification.delete.message": "

Vous êtes en train de supprimer une notification de produit.

Êtes-vous sûr de vouloir l’effacer?

", + "product.notification.delete.success.message": "Votre notification de produit a été supprimée.", + "product.notification.edit.form.cancel.button.label": "Annuler", + "product.notification.edit.form.create.button.label": "OK", + "product.notification.edit.form.email.label": "Courriel", + "product.notification.edit.form.instock_notification.label": "Prévenez-moi lorsque le produit est de nouveau en stock.", + "product.notification.edit.form.no_notification.label": "Je ne souhaite plus recevoir des nouvelles de cet article.", + "product.notification.edit.form.price.error.required": "Veuillez entrer une valeur monétaire valide.", + "product.notification.edit.form.price.error.valid": "Veuillez entrer une valeur monétaire valide.", + "product.notification.edit.form.price.label": "Prix", + "product.notification.edit.form.price_notification.label": "Prévenez-moi si le prix baisse à :", + "product.notification.edit.form.update.button.label": "Mettre à jour", + "product.notification.edit.link.title": "Modifier la notification", + "product.notification.price.notification.button.label": "Notification de prix", + "product.notification.price.notification.button.title": "Modifier la notification", + "product.notification.stock.notification.button.label": "Prévenez-moi lorsque disponible", + "product.notification.stock.notification.button.title": "Modifier la notification", + "product.notification.update.success.message": "Votre notification de produit a été mise à jour.", "product.out_of_stock.text": "Stock épuisé", "product.price.listPriceFallback.text": "{{0}}", "product.price.na.text": "n.c.", diff --git a/src/environments/environment.model.ts b/src/environments/environment.model.ts index 272d4467c3..6808d9fafe 100644 --- a/src/environments/environment.model.ts +++ b/src/environments/environment.model.ts @@ -26,6 +26,7 @@ export interface Environment { | 'compare' | 'rating' | 'recently' + | 'productNotifications' | 'storeLocator' | 'contactUs' /* B2B features */ @@ -142,7 +143,7 @@ export const ENVIRONMENT_DEFAULTS: Omit = { hybridApplication: '-', /* FEATURE TOGGLES */ - features: ['compare', 'contactUs', 'rating', 'recently', 'storeLocator'], + features: ['compare', 'contactUs', 'productNotifications', 'rating', 'recently', 'storeLocator'], /* PROGRESSIVE WEB APP CONFIGURATIONS */ smallBreakpointWidth: 576,