diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9d66a22065..9090d4c99d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -150,6 +150,7 @@ jobs: 'checkout/*b2c*', 'shopping/*b2c*', 'system/*b2c*', + 'extras/*b2c*', # TODO: include remaining in a general way # '!(shopping|account|checkout)*/*b2c*' # '@(cms|contact)*/*b2c*' diff --git a/e2e/cypress/integration/framework/index.ts b/e2e/cypress/integration/framework/index.ts index 1a22009dc6..71ffccfff3 100644 --- a/e2e/cypress/integration/framework/index.ts +++ b/e2e/cypress/integration/framework/index.ts @@ -16,7 +16,7 @@ function onPage(page: new () => T) { } export function at(type: new () => T, callback?: (page: T) => void) { - onPage(type).should('be.hidden'); + onPage(type).should('exist'); waitLoadingEnd(); if (callback) { callback(currentPage as T); diff --git a/e2e/cypress/integration/pages/account/add-to-wishlist.module.ts b/e2e/cypress/integration/pages/account/add-to-wishlist.module.ts new file mode 100644 index 0000000000..8fc69c0519 --- /dev/null +++ b/e2e/cypress/integration/pages/account/add-to-wishlist.module.ts @@ -0,0 +1,34 @@ +export class AddToWishlistModule { + constructor(private contextSelector: string = 'ish-product-listing') {} + + addProductToWishlistFromPage(title: string = '', modal: boolean = false) { + if (modal) { + cy.get(`[data-testing-id="${title}"]`).click(); + cy.get('[class="modal-footer"] button.btn-primary').click(); + } + this.closeAddProductToWishlistModal('link'); + } + + addProductToWishlistFromList(product: string, title: string, modal: boolean = true) { + if (modal) { + cy.get(this.contextSelector) + .find(`ish-product-item div[data-testing-sku="${product}"] button.add-to-wishlist`) + .click(); + cy.get(`[data-testing-id="${title}"]`).click(); + cy.get('[class="modal-footer"] button.btn-primary').click(); + this.closeAddProductToWishlistModal('link'); + } else { + cy.get(this.contextSelector) + .find(`ish-product-item div[data-testing-sku="${product}"] button.add-to-wishlist`) + .click(); + this.closeAddProductToWishlistModal('link'); + } + } + + private closeAddProductToWishlistModal(mode: 'link' | 'x') { + cy.wait(500); + mode === 'link' + ? cy.get('[data-testing-id="wishlist-success-link"] a').click() + : cy.get('[class="modal-header"] button').click(); + } +} diff --git a/e2e/cypress/integration/pages/account/login.page.ts b/e2e/cypress/integration/pages/account/login.page.ts index ef367c6719..7eab2e153d 100644 --- a/e2e/cypress/integration/pages/account/login.page.ts +++ b/e2e/cypress/integration/pages/account/login.page.ts @@ -28,13 +28,13 @@ export class LoginPage { submit() { cy.server() - .route('GET', '**/customers/**') - .as('customers'); + .route('GET', /.*\/customers\/-.*/) + .as('currentCustomer'); cy.wait(500); cy.get('button[name="login"]').click(); - return cy.wait('@customers'); + return cy.wait('@currentCustomer'); } get errorText() { diff --git a/e2e/cypress/integration/pages/account/my-account.page.ts b/e2e/cypress/integration/pages/account/my-account.page.ts index 7088e07d62..b1361a91d9 100644 --- a/e2e/cypress/integration/pages/account/my-account.page.ts +++ b/e2e/cypress/integration/pages/account/my-account.page.ts @@ -32,4 +32,8 @@ export class MyAccountPage { navigateToAddresses() { cy.get('a[data-testing-id="addresses-link"]').click(); } + + navigateToWishlists() { + cy.get('a[data-testing-id="wishlists-link"]').click(); + } } diff --git a/e2e/cypress/integration/pages/account/profile-edit-password.page.ts b/e2e/cypress/integration/pages/account/profile-edit-password.page.ts index 79f019fa20..3edeae659e 100644 --- a/e2e/cypress/integration/pages/account/profile-edit-password.page.ts +++ b/e2e/cypress/integration/pages/account/profile-edit-password.page.ts @@ -12,14 +12,14 @@ export class ProfileEditPasswordPage { submit() { cy.server() - .route('PUT', '**/customers/**') - .as('customers'); + .route('PUT', '**/password') + .as('passwordChange'); cy.wait(500); cy.get(this.tag) .find('button[type="submit"]') .click(); - return cy.wait('@customers'); + return cy.wait('@passwordChange'); } } diff --git a/e2e/cypress/integration/pages/account/wishlists-details.page.ts b/e2e/cypress/integration/pages/account/wishlists-details.page.ts new file mode 100644 index 0000000000..4811f06a4d --- /dev/null +++ b/e2e/cypress/integration/pages/account/wishlists-details.page.ts @@ -0,0 +1,80 @@ +import { HeaderModule } from '../header.module'; + +export class WishlistsDetailsPage { + readonly tag = 'ish-account-wishlist-detail-page'; + + readonly header = new HeaderModule(); + + static navigateToOverviewPage() { + cy.get('[href="/account/wishlists"]') + .first() + .click(); + } + + get listItem() { + return cy.get('ish-account-wishlist-detail-line-item'); + } + + get listItemLink() { + return cy.get('ish-account-wishlist-detail-line-item').find('a[data-testing-id="wishlist-product-link"]'); + } + + get wishlistTitle() { + return cy + .get('ish-account-wishlist-detail-page') + .find('h1') + .invoke('text'); + } + + get wishlistPreferredTextElement() { + return cy.get('[data-testing-id="preferred-wishlist-text"]'); + } + + getWishlistItemById(id: string) { + return cy + .get('span') + .contains(id) + .closest('ish-account-wishlist-detail-line-item') + .parent(); + } + + editWishlistDetails(name: string, preferred: boolean) { + cy.get('[data-testing-id="wishlist-details-edit"]').click(); + cy.get('ngb-modal-window') + .find('[data-testing-id="title"]') + .clear() + .type(name); + cy.get('[data-testing-id="preferred"]').uncheck(); + if (preferred) { + cy.get('[data-testing-id="preferred"]').check(); + } + cy.get('[data-testing-id="wishlist-dialog-submit"]').click(); + } + + deleteWishlist(id: string) { + this.getWishlistItemById(id) + .find('[data-testing-id="delete-wishlist"]') + .click(); + } + + moveProductToWishlist(productId: string, listName: string) { + this.getWishlistItemById(productId) + .find('[data-testing-id="move-wishlist"]') + .click(); + cy.get(`[data-testing-id="${listName}"]`).check(); + cy.get('ngb-modal-window') + .find('button[class="btn btn-primary"]') + .click(); + cy.get('[data-testing-id="wishlist-success-link"] a').click(); + } + + addProductToBasket(productId: string, quantity: number) { + this.getWishlistItemById(productId) + .find('[data-testing-id="quantity"]') + .clear() + .type(quantity.toString()); + this.getWishlistItemById(productId) + .find('[data-testing-id="addToCartButton"]') + .click(); + } +} diff --git a/e2e/cypress/integration/pages/account/wishlists-overview.page.ts b/e2e/cypress/integration/pages/account/wishlists-overview.page.ts new file mode 100644 index 0000000000..8867ebf552 --- /dev/null +++ b/e2e/cypress/integration/pages/account/wishlists-overview.page.ts @@ -0,0 +1,49 @@ +import { HeaderModule } from '../header.module'; + +export class WishlistsOverviewPage { + readonly tag = 'ish-account-wishlist-page'; + + readonly header = new HeaderModule(); + + static navigateTo() { + cy.visit('/account/wishlists'); + } + + addWishlist(name: string, preferred: boolean) { + cy.get('a[data-testing-id="add-wishlist"').click(); + cy.get('[data-testing-id="wishlist-dialog-name"]') + .find('[data-testing-id="title"]') + .clear() + .type(name); + if (preferred) { + cy.get('[data-testing-id="wishlist-dialog-preferred"]') + .find('[data-testing-id="preferred"]') + .check(); + } + cy.get('[data-testing-id="wishlist-dialog-submit"]').click(); + } + + deleteWishlistById(id: string) { + this.wishlistsArray + .find('a') + .contains(id) + .closest('[data-testing-id="wishlist-list-item-container"]') + .find('[data-testing-id="delete-wishlist"]') + .click(); + cy.get('[data-testing-id="confirm"]').click(); + } + + goToWishlistDetailLink(name: string) { + cy.get('a') + .contains(name) + .click(); + } + + get wishlistsArray() { + return cy.get('[data-testing-id="wishlist-list-item"]'); + } + + get wishlistsTitlesArray() { + return this.wishlistsArray.find('[data-testing-id="wishlist-list-title"]').invoke('text'); + } +} diff --git a/e2e/cypress/integration/pages/checkout/cart.page.ts b/e2e/cypress/integration/pages/checkout/cart.page.ts index f39a12d780..f713fc549b 100644 --- a/e2e/cypress/integration/pages/checkout/cart.page.ts +++ b/e2e/cypress/integration/pages/checkout/cart.page.ts @@ -1,9 +1,11 @@ +import { AddToWishlistModule } from '../account/add-to-wishlist.module'; import { HeaderModule } from '../header.module'; export class CartPage { readonly tag = 'ish-shopping-basket'; readonly header = new HeaderModule(); + readonly addToWishlist = new AddToWishlistModule(); private saveQuoteRequestButton = () => cy.get('[id="createQuote"]'); @@ -19,10 +21,16 @@ export class CartPage { this.saveQuoteRequestButton().click(); } + private addToWishlistButton = () => cy.get('ish-shopping-basket').find('[data-testing-id="addToWishlistButton"]'); + get lineItems() { return cy.get(this.tag).find('div.pli-description'); } + addProductToWishlist() { + this.addToWishlistButton().click(); + } + lineItem(idx: number) { return { quantity: { diff --git a/e2e/cypress/integration/pages/shopping/product-detail.page.ts b/e2e/cypress/integration/pages/shopping/product-detail.page.ts index 70da4a1090..a7c7ade90a 100644 --- a/e2e/cypress/integration/pages/shopping/product-detail.page.ts +++ b/e2e/cypress/integration/pages/shopping/product-detail.page.ts @@ -1,3 +1,4 @@ +import { AddToWishlistModule } from '../account/add-to-wishlist.module'; import { BreadcrumbModule } from '../breadcrumb.module'; import { HeaderModule } from '../header.module'; @@ -13,6 +14,8 @@ export class ProductDetailPage { readonly retailSetParts = new ProductListModule('ish-retail-set-parts'); readonly variations = new ProductListModule('ish-product-master-variations'); + readonly addToWishlist = new AddToWishlistModule(); + static navigateTo(sku: string, categoryUniqueId?: string) { if (categoryUniqueId) { cy.visit(`/category/${categoryUniqueId}/product/${sku}`); @@ -25,6 +28,10 @@ export class ProductDetailPage { private addToCompareButton() { return cy.get('ish-product-detail').find('ish-product-detail-actions [data-testing-id*="compare"] .share-label'); } + private addToWishlistButton() { + return cy.get('ish-product-detail').find('ish-product-detail-actions [data-testing-id*="wishlist"] .share-label'); + } + private addToQuoteRequestButton = () => cy.get('ish-product-detail').find('[data-testing-id="addToQuoteButton"]'); private quantityInput = () => cy.get('ish-product-detail').find('[data-testing-id="quantity"]'); @@ -66,6 +73,10 @@ export class ProductDetailPage { this.addToQuoteRequestButton().click(); } + addProductToWishlist() { + this.addToWishlistButton().click(); + } + setQuantity(quantity: number) { this.quantityInput().clear(); this.quantityInput().type(quantity.toString()); diff --git a/e2e/cypress/integration/pages/shopping/product-list.module.ts b/e2e/cypress/integration/pages/shopping/product-list.module.ts index 41248a17a4..c571b7ffcd 100644 --- a/e2e/cypress/integration/pages/shopping/product-list.module.ts +++ b/e2e/cypress/integration/pages/shopping/product-list.module.ts @@ -1,6 +1,8 @@ import { waitLoadingEnd } from '../../framework'; +import { AddToWishlistModule } from '../account/add-to-wishlist.module'; export class ProductListModule { + readonly addToWishlist = new AddToWishlistModule(); constructor(private contextSelector: string) {} get visibleProducts() { diff --git a/e2e/cypress/integration/specs/shopping/compare-products.mock.b2c.e2e-spec.ts b/e2e/cypress/integration/specs/extras/compare-products.mock.b2c.e2e-spec.ts similarity index 100% rename from e2e/cypress/integration/specs/shopping/compare-products.mock.b2c.e2e-spec.ts rename to e2e/cypress/integration/specs/extras/compare-products.mock.b2c.e2e-spec.ts diff --git a/e2e/cypress/integration/specs/extras/wishlists-management.b2c.e2e-spec.ts b/e2e/cypress/integration/specs/extras/wishlists-management.b2c.e2e-spec.ts new file mode 100644 index 0000000000..c701b37b1b --- /dev/null +++ b/e2e/cypress/integration/specs/extras/wishlists-management.b2c.e2e-spec.ts @@ -0,0 +1,135 @@ +import { at } from '../../framework'; +import { createUserViaREST } from '../../framework/users'; +import { LoginPage } from '../../pages/account/login.page'; +import { sensibleDefaults } from '../../pages/account/registration.page'; +import { WishlistsDetailsPage } from '../../pages/account/wishlists-details.page'; +import { WishlistsOverviewPage } from '../../pages/account/wishlists-overview.page'; +import { CategoryPage } from '../../pages/shopping/category.page'; +import { FamilyPage } from '../../pages/shopping/family.page'; +import { ProductDetailPage } from '../../pages/shopping/product-detail.page'; + +const _ = { + user: { + login: `test${new Date().getTime()}@testcity.de`, + ...sensibleDefaults, + }, + category: 'Home-Entertainment', + subcategory: 'Home-Entertainment.SmartHome', + product1: '201807171', + product2: '201807194', + product3: '201807191', +}; + +describe('Wishlist MyAccount Functionality', () => { + const preferredWishlist = 'preferred wishlist'; + const unpreferredWishlist = 'unpreferred wishlist'; + const editedWishlist = 'edited wishlist'; + const anotherWishlist = 'another wishlist'; + + before(() => { + createUserViaREST(_.user); + LoginPage.navigateTo(); + at(LoginPage, page => { + page.fillForm(_.user.login, _.user.password); + page + .submit() + .its('status') + .should('equal', 200); + }); + WishlistsOverviewPage.navigateTo(); + }); + + it('user creates an unpreferred wishlist', () => { + at(WishlistsOverviewPage, page => { + page.addWishlist(unpreferredWishlist, false); + page.wishlistsArray.should('have.length', 1); + page.wishlistsTitlesArray.should('contain', unpreferredWishlist); + }); + }); + + it('user creates a preferred wishlist', () => { + at(WishlistsOverviewPage, page => { + page.addWishlist(preferredWishlist, true); + page.wishlistsArray.should('have.length', 2); + page.wishlistsTitlesArray.should('contain', preferredWishlist); + }); + }); + + it('user deletes first wishlist', () => { + at(WishlistsOverviewPage, page => { + page.wishlistsArray.then($listItems => { + const initLen = $listItems.length; + const firstItem = $listItems.first(); + + page.deleteWishlistById('unpreferred wishlist'); + page.wishlistsArray.should('have.length', initLen - 1); + page.wishlistsArray.should('not.include', firstItem); + }); + }); + }); + + it('user adds 2 products from the product detail page to wishlist (using preferred wishlist)', () => { + at(WishlistsOverviewPage, page => { + page.addWishlist(anotherWishlist, false); + page.header.gotoCategoryPage(_.category); + }); + + at(CategoryPage, page => page.gotoSubCategory(_.subcategory)); + at(FamilyPage, page => page.productList.gotoProductDetailPageBySku(_.product2)); + at(ProductDetailPage, page => { + page.addProductToWishlist(); + page.addToWishlist.addProductToWishlistFromPage(); + }); + at(WishlistsDetailsPage, page => { + page.listItemLink.invoke('attr', 'href').should('contain', _.product2); + page.header.gotoCategoryPage(_.category); + }); + + at(CategoryPage, page => page.gotoSubCategory(_.subcategory)); + at(FamilyPage, page => page.productList.gotoProductDetailPageBySku(_.product1)); + at(ProductDetailPage, page => { + page.addProductToWishlist(); + page.addToWishlist.addProductToWishlistFromPage(); + }); + at(WishlistsDetailsPage, page => { + page.listItemLink.should('have.length', 2); + }); + }); + + it('user renames a wishlist and sets it to unpreferred', () => { + at(WishlistsDetailsPage, page => { + page.editWishlistDetails(editedWishlist, false); + page.wishlistTitle.should('equal', editedWishlist); + page.wishlistPreferredTextElement.should('not.exist'); + }); + }); + + it('user deletes a product from wishlist', () => { + at(WishlistsDetailsPage, page => { + page.listItemLink.then($listItems => { + const initLength = $listItems.length; + page.deleteWishlist(_.product2); + cy.wait(500); + page.listItemLink.invoke('attr', 'href').should('not.contain', _.product2); + page.listItemLink.should('have.length', initLength - 1); + }); + }); + }); + + it('user moves a product to another wishlist', () => { + at(WishlistsDetailsPage, page => { + page.moveProductToWishlist(_.product1, anotherWishlist); + cy.wait(500); + page.wishlistTitle.should('equal', anotherWishlist); + page.getWishlistItemById(_.product1).should('exist'); + }); + WishlistsOverviewPage.navigateTo(); + at(WishlistsOverviewPage, page => { + page.goToWishlistDetailLink(editedWishlist); + }); + + at(WishlistsDetailsPage, page => { + page.listItem.should('not.exist'); + }); + }); +}); diff --git a/e2e/cypress/integration/specs/extras/wishlists-shopping.b2c.e2e-spec.ts b/e2e/cypress/integration/specs/extras/wishlists-shopping.b2c.e2e-spec.ts new file mode 100644 index 0000000000..c9ddf0d5bf --- /dev/null +++ b/e2e/cypress/integration/specs/extras/wishlists-shopping.b2c.e2e-spec.ts @@ -0,0 +1,93 @@ +import { at } from '../../framework'; +import { createUserViaREST } from '../../framework/users'; +import { LoginPage } from '../../pages/account/login.page'; +import { sensibleDefaults } from '../../pages/account/registration.page'; +import { WishlistsDetailsPage } from '../../pages/account/wishlists-details.page'; +import { WishlistsOverviewPage } from '../../pages/account/wishlists-overview.page'; +import { CartPage } from '../../pages/checkout/cart.page'; +import { CategoryPage } from '../../pages/shopping/category.page'; +import { FamilyPage } from '../../pages/shopping/family.page'; + +const _ = { + user: { + login: `test${new Date().getTime()}@testcity.de`, + ...sensibleDefaults, + }, + category: 'Home-Entertainment', + subcategory: 'Home-Entertainment.SmartHome', + product1: '201807171', + product2: '201807194', + product3: '201807191', +}; + +describe('Wishlist Shopping Experience Functionality', () => { + const unpreferredWishlist = 'unpreferred wishlist'; + const shoppingUnpreferred = 'shopping wishlist'; + const shoppingPreferred = 'shopping wishlist preferred'; + before(() => { + createUserViaREST(_.user); + LoginPage.navigateTo(); + at(LoginPage, page => { + page.fillForm(_.user.login, _.user.password); + page + .submit() + .its('status') + .should('equal', 200); + }); + WishlistsOverviewPage.navigateTo(); + at(WishlistsOverviewPage, page => { + page.addWishlist(unpreferredWishlist, false); + page.addWishlist(shoppingUnpreferred, false); + }); + }); + + it('user adds a product from the product tile to wishlist (with selecting a wishlist)', () => { + at(WishlistsOverviewPage, page => { + page.header.gotoCategoryPage(_.category); + }); + + at(CategoryPage, page => page.gotoSubCategory(_.subcategory)); + at(FamilyPage, page => + page.productList.addToWishlist.addProductToWishlistFromList(_.product1, unpreferredWishlist) + ); + at(WishlistsDetailsPage, page => page.listItemLink.invoke('attr', 'href').should('contain', _.product1)); + }); + + it('user adds a wishlist product to cart', () => { + at(WishlistsDetailsPage, page => { + page.addProductToBasket(_.product1, 4); + page.header.miniCart.goToCart(); + }); + at(CartPage, page => { + page.lineItems.contains(_.product1).should('exist'); + page.lineItems + .contains(_.product1) + .closest('[data-testing-id="product-list-item"]') + .find('[data-testing-id="quantity"]') + .should('have.value', '4'); + }); + }); + + it('user adds a product to wishlist from shopping cart (with wishlist selection)', () => { + at(CartPage, page => { + page.addProductToWishlist(); + page.addToWishlist.addProductToWishlistFromPage(shoppingUnpreferred, true); + }); + at(WishlistsDetailsPage, page => { + page.listItemLink.invoke('attr', 'href').should('contain', _.product1); + }); + }); + + it('user adds a product to wishlist from shopping cart (to a preferred wishlist without selection)', () => { + WishlistsOverviewPage.navigateTo(); + at(WishlistsOverviewPage, page => { + page.addWishlist(shoppingPreferred, true); + page.header.miniCart.goToCart(); + }); + at(CartPage, page => { + page.addProductToWishlist(); + page.addToWishlist.addProductToWishlistFromPage(shoppingPreferred, false); + }); + at(WishlistsDetailsPage, page => page.listItemLink.invoke('attr', 'href').should('contain', _.product1)); + }); +}); diff --git a/src/app/core/icon.module.ts b/src/app/core/icon.module.ts index a06b2e4eee..1de5ead2e8 100644 --- a/src/app/core/icon.module.ts +++ b/src/app/core/icon.module.ts @@ -6,11 +6,13 @@ import { faAngleDown, faAngleRight, faAngleUp, + faArrowsAlt, faBars, faCheck, faCog, faColumns, faGlobeAmericas, + faHeart, faHome, faInbox, faInfoCircle, @@ -48,6 +50,7 @@ export class IconModule { faAngleDown, faAngleRight, faAngleUp, + faArrowsAlt, faBars, faCheck, faCog, @@ -75,7 +78,8 @@ export class IconModule { faTrashAlt, faUser, faStar, - faStarHalf + faStarHalf, + faHeart ); } } diff --git a/src/app/core/pipes/date.pipe.ts b/src/app/core/pipes/date.pipe.ts index c2e6a1a233..2f477684d8 100644 --- a/src/app/core/pipes/date.pipe.ts +++ b/src/app/core/pipes/date.pipe.ts @@ -22,6 +22,13 @@ export function formatISHDate( return formatDate(date, format, lang, timezone || '+0000'); } +/** + * The date pipe converts a number, string or date into a localized date format + * example values: + * as number: 1581690101334 + * as string: '01 Jan 1970 00:00:00 GMT' + * other parameters see also angular date pipe + */ @Pipe({ name: 'ishDate', pure: true }) export class DatePipe implements PipeTransform { constructor(private translateService: TranslateService) {} diff --git a/src/app/extensions/quoting/shared/product/product-add-to-quote/product-add-to-quote.component.ts b/src/app/extensions/quoting/shared/product/product-add-to-quote/product-add-to-quote.component.ts index d080022495..f47d040187 100644 --- a/src/app/extensions/quoting/shared/product/product-add-to-quote/product-add-to-quote.component.ts +++ b/src/app/extensions/quoting/shared/product/product-add-to-quote/product-add-to-quote.component.ts @@ -21,7 +21,7 @@ import { ProductAddToQuoteDialogComponent } from '../product-add-to-quote-dialog export class ProductAddToQuoteComponent { @Input() product: Product; @Input() disabled?: boolean; - @Input() displayType?: string; + @Input() displayType?: 'icon' | 'link' = 'link'; @Input() class?: string; @Input() quantity?: number; diff --git a/src/app/extensions/wishlists/exports/products/lazy-product-add-to-wishlist/lazy-product-add-to-wishlist.component.html b/src/app/extensions/wishlists/exports/products/lazy-product-add-to-wishlist/lazy-product-add-to-wishlist.component.html new file mode 100644 index 0000000000..b50aefb625 --- /dev/null +++ b/src/app/extensions/wishlists/exports/products/lazy-product-add-to-wishlist/lazy-product-add-to-wishlist.component.html @@ -0,0 +1,5 @@ + diff --git a/src/app/extensions/wishlists/exports/products/lazy-product-add-to-wishlist/lazy-product-add-to-wishlist.component.ts b/src/app/extensions/wishlists/exports/products/lazy-product-add-to-wishlist/lazy-product-add-to-wishlist.component.ts new file mode 100644 index 0000000000..d2645f419f --- /dev/null +++ b/src/app/extensions/wishlists/exports/products/lazy-product-add-to-wishlist/lazy-product-add-to-wishlist.component.ts @@ -0,0 +1,18 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; + +import { Product } from 'ish-core/models/product/product.model'; + +@Component({ + selector: 'ish-lazy-product-add-to-wishlist', + templateUrl: './lazy-product-add-to-wishlist.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +// tslint:disable-next-line:component-creation-test +export class LazyProductAddToWishlistComponent { + @Input() product: Product; + @Input() displayType?: string; + componentLocation = { + moduleId: 'ish-extensions-wishlists', + selector: 'ish-product-add-to-wishlist', + }; +} diff --git a/src/app/extensions/wishlists/exports/wishlists-exports.module.ts b/src/app/extensions/wishlists/exports/wishlists-exports.module.ts new file mode 100644 index 0000000000..02392d5841 --- /dev/null +++ b/src/app/extensions/wishlists/exports/wishlists-exports.module.ts @@ -0,0 +1,20 @@ +import { NgModule } from '@angular/core'; +import { ReactiveComponentLoaderModule } from '@wishtack/reactive-component-loader'; + +import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; + +import { LazyProductAddToWishlistComponent } from './products/lazy-product-add-to-wishlist/lazy-product-add-to-wishlist.component'; +import { LazyWishlistLinkComponent } from './wishlists/lazy-wishlist-link/lazy-wishlist-link.component'; + +@NgModule({ + imports: [ + FeatureToggleModule, + ReactiveComponentLoaderModule.withModule({ + moduleId: 'ish-extensions-wishlists', + loadChildren: '../wishlists.module#WishlistsModule', + }), + ], + declarations: [LazyProductAddToWishlistComponent, LazyWishlistLinkComponent], + exports: [LazyProductAddToWishlistComponent, LazyWishlistLinkComponent], +}) +export class WishlistsExportsModule {} diff --git a/src/app/extensions/wishlists/exports/wishlists/lazy-wishlist-link/lazy-wishlist-link.component.html b/src/app/extensions/wishlists/exports/wishlists/lazy-wishlist-link/lazy-wishlist-link.component.html new file mode 100644 index 0000000000..820468350c --- /dev/null +++ b/src/app/extensions/wishlists/exports/wishlists/lazy-wishlist-link/lazy-wishlist-link.component.html @@ -0,0 +1 @@ + diff --git a/src/app/extensions/wishlists/exports/wishlists/lazy-wishlist-link/lazy-wishlist-link.component.ts b/src/app/extensions/wishlists/exports/wishlists/lazy-wishlist-link/lazy-wishlist-link.component.ts new file mode 100644 index 0000000000..9846708aa7 --- /dev/null +++ b/src/app/extensions/wishlists/exports/wishlists/lazy-wishlist-link/lazy-wishlist-link.component.ts @@ -0,0 +1,15 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; + +@Component({ + selector: 'ish-lazy-wishlist-link', + templateUrl: './lazy-wishlist-link.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +// tslint:disable-next-line:component-creation-test +export class LazyWishlistLinkComponent { + @Input() view: 'auto' | 'full' | 'small' = 'auto'; + componentLocation = { + moduleId: 'ish-extensions-wishlists', + selector: 'ish-wishlists-link', + }; +} diff --git a/src/app/extensions/wishlists/facades/wishlists.facade.ts b/src/app/extensions/wishlists/facades/wishlists.facade.ts new file mode 100644 index 0000000000..917d6a3526 --- /dev/null +++ b/src/app/extensions/wishlists/facades/wishlists.facade.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@angular/core'; +import { Store, select } from '@ngrx/store'; +import { Observable } from 'rxjs'; + +import { HttpError } from 'ish-core/models/http-error/http-error.model'; + +import { Wishlist, WishlistHeader } from '../models/wishlist/wishlist.model'; +import { + AddProductToNewWishlist, + AddProductToWishlist, + CreateWishlist, + DeleteWishlist, + MoveItemToWishlist, + RemoveItemFromWishlist, + UpdateWishlist, + getAllWishlists, + getPreferredWishlist, + getSelectedWishlistDetails, + getWishlistsError, + getWishlistsLoading, +} from '../store/wishlist'; + +// tslint:disable:member-ordering +@Injectable({ providedIn: 'root' }) +export class WishlistsFacade { + constructor(private store: Store<{}>) {} + + wishlists$: Observable = this.store.pipe(select(getAllWishlists)); + currentWishlist$: Observable = this.store.pipe(select(getSelectedWishlistDetails)); + preferredWishlist$: Observable = this.store.pipe(select(getPreferredWishlist)); + wishlistLoading$: Observable = this.store.pipe(select(getWishlistsLoading)); + wishlistError$: Observable = this.store.pipe(select(getWishlistsError)); + + addWishlist(wishlist: WishlistHeader): void | HttpError { + this.store.dispatch(new CreateWishlist({ wishlist })); + } + + deleteWishlist(id: string): void { + this.store.dispatch(new DeleteWishlist({ wishlistId: id })); + } + + updateWishlist(wishlist: Wishlist): void { + this.store.dispatch(new UpdateWishlist({ wishlist })); + } + + addProductToNewWishlist(title: string, sku: string): void { + this.store.dispatch(new AddProductToNewWishlist({ title, sku })); + } + + addProductToWishlist(wishlistId: string, sku: string, quantity?: number): void { + this.store.dispatch(new AddProductToWishlist({ wishlistId, sku, quantity })); + } + + moveItemToWishlist(sourceWishlistId: string, targetWishlistId: string, sku: string): void { + this.store.dispatch( + new MoveItemToWishlist({ source: { id: sourceWishlistId }, target: { id: targetWishlistId, sku } }) + ); + } + + moveItemToNewWishlist(sourceWishlistId: string, title: string, sku: string): void { + this.store.dispatch(new MoveItemToWishlist({ source: { id: sourceWishlistId }, target: { title, sku } })); + } + + removeProductFromWishlist(wishlistId: string, sku: string): void { + this.store.dispatch(new RemoveItemFromWishlist({ wishlistId, sku })); + } +} diff --git a/src/app/extensions/wishlists/models/wishlist/wishlist.interface.ts b/src/app/extensions/wishlists/models/wishlist/wishlist.interface.ts new file mode 100644 index 0000000000..9eb56794e8 --- /dev/null +++ b/src/app/extensions/wishlists/models/wishlist/wishlist.interface.ts @@ -0,0 +1,11 @@ +import { Attribute } from 'ish-core/models/attribute/attribute.model'; + +import { WishlistHeader } from './wishlist.model'; + +export interface WishlistData extends WishlistHeader { + items?: { attributes: Attribute[] }[]; + itemsCount?: number; + public?: boolean; + name?: string; + uri?: string; +} diff --git a/src/app/extensions/wishlists/models/wishlist/wishlist.mapper.spec.ts b/src/app/extensions/wishlists/models/wishlist/wishlist.mapper.spec.ts new file mode 100644 index 0000000000..a0f0931e3e --- /dev/null +++ b/src/app/extensions/wishlists/models/wishlist/wishlist.mapper.spec.ts @@ -0,0 +1,71 @@ +import { TestBed } from '@angular/core/testing'; + +import { WishlistData } from './wishlist.interface'; +import { WishlistMapper } from './wishlist.mapper'; +import { Wishlist } from './wishlist.model'; + +describe('Wishlist Mapper', () => { + let wishlistMapper: WishlistMapper; + + beforeEach(() => { + TestBed.configureTestingModule({}); + wishlistMapper = TestBed.get(WishlistMapper); + }); + + describe('fromData', () => { + it('should throw when input is falsy', () => { + expect(() => wishlistMapper.fromData(undefined, undefined)).toThrow(); + }); + + it('should map incoming data to wishlist model data', () => { + const wishlistData: WishlistData = { + title: 'wishlist title', + itemsCount: 3, + preferred: true, + public: true, + items: [ + { + attributes: [ + { name: 'sku', value: '123456' }, + { name: 'id', value: 'wishlistItemId' }, + { name: 'creationDate', value: '12345818123' }, + { + name: 'desiredQuantity', + value: { + value: 2, + unit: '', + }, + }, + ], + }, + ], + }; + const mapped = wishlistMapper.fromData(wishlistData, '1234'); + expect(mapped).toHaveProperty('id', '1234'); + expect(mapped).toHaveProperty('title', 'wishlist title'); + expect(mapped).toHaveProperty('items', [ + { sku: '123456', id: 'wishlistItemId', creationDate: 12345818123, desiredQuantity: { value: 2 } }, + ]); + expect(mapped).not.toHaveProperty('creationDate'); + }); + }); + + describe('fromUpdate', () => { + it('should map incoming data to wishlist', () => { + const wishlistId = '1234'; + + const updateWishlistData: Wishlist = { + id: wishlistId, + title: 'title', + preferred: true, + public: false, + }; + const mapped = wishlistMapper.fromUpdate(updateWishlistData, wishlistId); + + expect(mapped).toHaveProperty('id', wishlistId); + expect(mapped).toHaveProperty('title', 'title'); + expect(mapped).toHaveProperty('preferred', true); + expect(mapped).toHaveProperty('public', false); + }); + }); +}); diff --git a/src/app/extensions/wishlists/models/wishlist/wishlist.mapper.ts b/src/app/extensions/wishlists/models/wishlist/wishlist.mapper.ts new file mode 100644 index 0000000000..930795153f --- /dev/null +++ b/src/app/extensions/wishlists/models/wishlist/wishlist.mapper.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@angular/core'; + +import { WishlistData } from './wishlist.interface'; +import { Wishlist, WishlistItem } from './wishlist.model'; + +@Injectable({ providedIn: 'root' }) +export class WishlistMapper { + private static parseIDfromURI(uri: string): string { + const match = /wishlists[^\/]*\/([^\?]*)/.exec(uri); + if (match) { + return match[1]; + } else { + console.warn(`could not find id in uri '${uri}'`); + return; + } + } + fromData(wishlistData: WishlistData, wishlistId: string): Wishlist { + if (wishlistData) { + let items: WishlistItem[]; + if (wishlistData.items && wishlistData.items.length) { + // create items object from attribute array + const arrayToObject = attributes => + attributes.reduce((obj, attr) => { + obj[attr.name] = attr.value; + return obj; + }, {}); + items = wishlistData.items + .map(item => arrayToObject(item.attributes)) + .map(item => ({ + sku: item.sku, + id: item.id, + creationDate: Number(item.creationDate), + desiredQuantity: { + value: item.desiredQuantity.value, + // TBD: is the unit necessarry? + // unit: item.desiredQuantity.unit, + }, + })); + } else { + items = []; + } + return { + id: wishlistId, + title: wishlistData.title, + itemsCount: wishlistData.itemsCount || 0, + preferred: wishlistData.preferred, + public: wishlistData.public, + items, + }; + } else { + throw new Error(`wishlistData is required`); + } + } + + fromUpdate(wishlist: Wishlist, id: string): Wishlist { + if (wishlist && id) { + return { + id, + title: wishlist.title, + preferred: wishlist.preferred, + public: wishlist.public, + }; + } + } + + // extract ID from URI + fromDataToIds(wishlistData: WishlistData): Wishlist { + if (wishlistData) { + return { + id: WishlistMapper.parseIDfromURI(wishlistData.uri), + title: wishlistData.title, + preferred: wishlistData.preferred, + }; + } + } +} diff --git a/src/app/extensions/wishlists/models/wishlist/wishlist.model.ts b/src/app/extensions/wishlists/models/wishlist/wishlist.model.ts new file mode 100644 index 0000000000..38abb0e5e8 --- /dev/null +++ b/src/app/extensions/wishlists/models/wishlist/wishlist.model.ts @@ -0,0 +1,24 @@ +export interface WishlistHeader { + preferred: boolean; + title: string; +} +export interface Wishlist extends WishlistHeader { + id: string; + items?: WishlistItem[]; + itemsCount?: number; + public?: boolean; +} + +export interface WishlistItem { + sku: string; + id: string; + creationDate: number; + desiredQuantity: { + value: number; + unit?: string; + }; + purchasedQuantity?: { + value: number; + unit: string; + }; +} diff --git a/src/app/extensions/wishlists/pages/account-wishlist-detail/account-wishlist-detail-line-item/account-wishlist-detail-line-item.component.html b/src/app/extensions/wishlists/pages/account-wishlist-detail/account-wishlist-detail-line-item/account-wishlist-detail-line-item.component.html new file mode 100644 index 0000000000..0e3c100818 --- /dev/null +++ b/src/app/extensions/wishlists/pages/account-wishlist-detail/account-wishlist-detail-line-item/account-wishlist-detail-line-item.component.html @@ -0,0 +1,76 @@ + +
+
+ + + +
+
+
+
+
+ + {{ product.name }} + + + + + + +
+
+ +
+ +

{{ 'account.wishlist.table.in_stock' | translate }}

+ +

{{ 'account.wishlist.table.not_accessible' | translate }}

+
+
+
+ +
+
+ +
+
+
+
+
+
+ + +
diff --git a/src/app/extensions/wishlists/pages/account-wishlist-detail/account-wishlist-detail-line-item/account-wishlist-detail-line-item.component.spec.ts b/src/app/extensions/wishlists/pages/account-wishlist-detail/account-wishlist-detail-line-item/account-wishlist-detail-line-item.component.spec.ts new file mode 100644 index 0000000000..44c07edd9b --- /dev/null +++ b/src/app/extensions/wishlists/pages/account-wishlist-detail/account-wishlist-detail-line-item/account-wishlist-detail-line-item.component.spec.ts @@ -0,0 +1,69 @@ +import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent, MockPipe } from 'ng-mocks'; + +import { DatePipe } from 'ish-core/pipes/date.pipe'; +import { ProductRoutePipe } from 'ish-core/pipes/product-route.pipe'; +import { coreReducers } from 'ish-core/store/core-store.module'; +import { ngrxTesting } from 'ish-core/utils/dev/ngrx-testing'; +import { ProductAddToBasketComponent } from 'ish-shared/components/product/product-add-to-basket/product-add-to-basket.component'; +import { ProductBundleDisplayComponent } from 'ish-shared/components/product/product-bundle-display/product-bundle-display.component'; +import { ProductIdComponent } from 'ish-shared/components/product/product-id/product-id.component'; +import { ProductPriceComponent } from 'ish-shared/components/product/product-price/product-price.component'; +import { ProductQuantityComponent } from 'ish-shared/components/product/product-quantity/product-quantity.component'; +import { ProductVariationDisplayComponent } from 'ish-shared/components/product/product-variation-display/product-variation-display.component'; +import { InputComponent } from 'ish-shared/forms/components/input/input.component'; +import { ProductImageComponent } from 'ish-shell/header/product-image/product-image.component'; + +import { SelectWishlistModalComponent } from '../../../shared/wishlists/select-wishlist-modal/select-wishlist-modal.component'; + +import { AccountWishlistDetailLineItemComponent } from './account-wishlist-detail-line-item.component'; + +describe('Account Wishlist Detail Line Item Component', () => { + let component: AccountWishlistDetailLineItemComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + AccountWishlistDetailLineItemComponent, + MockComponent(FaIconComponent), + MockComponent(InputComponent), + MockComponent(ProductAddToBasketComponent), + MockComponent(ProductBundleDisplayComponent), + MockComponent(ProductIdComponent), + MockComponent(ProductImageComponent), + MockComponent(ProductPriceComponent), + MockComponent(ProductQuantityComponent), + MockComponent(ProductVariationDisplayComponent), + MockComponent(SelectWishlistModalComponent), + MockPipe(DatePipe), + MockPipe(ProductRoutePipe), + ], + imports: [ + ReactiveFormsModule, + RouterTestingModule, + TranslateModule.forRoot(), + ngrxTesting({ + reducers: coreReducers, + }), + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AccountWishlistDetailLineItemComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); +}); diff --git a/src/app/extensions/wishlists/pages/account-wishlist-detail/account-wishlist-detail-line-item/account-wishlist-detail-line-item.component.ts b/src/app/extensions/wishlists/pages/account-wishlist-detail/account-wishlist-detail-line-item/account-wishlist-detail-line-item.component.ts new file mode 100644 index 0000000000..cf9dc27483 --- /dev/null +++ b/src/app/extensions/wishlists/pages/account-wishlist-detail/account-wishlist-detail-line-item/account-wishlist-detail-line-item.component.ts @@ -0,0 +1,72 @@ +import { ChangeDetectionStrategy, Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { FormControl, FormGroup } from '@angular/forms'; +import { Observable } from 'rxjs'; + +import { ShoppingFacade } from 'ish-core/facades/shopping.facade'; +import { ProductView } from 'ish-core/models/product-view/product-view.model'; +import { ProductCompletenessLevel } from 'ish-core/models/product/product.model'; + +import { WishlistsFacade } from '../../../facades/wishlists.facade'; +import { Wishlist, WishlistItem } from '../../../models/wishlist/wishlist.model'; + +/** + * The Wishlist item component displays a wishlist item. This Item can be removed or moved to another wishlist. + */ +@Component({ + selector: 'ish-account-wishlist-detail-line-item', + templateUrl: './account-wishlist-detail-line-item.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AccountWishlistDetailLineItemComponent implements OnInit, OnChanges { + constructor(private productFacade: ShoppingFacade, private wishlistsFacade: WishlistsFacade) {} + + private static REQUIRED_COMPLETENESS_LEVEL = ProductCompletenessLevel.List; + @Input() wishlistItemData: WishlistItem; + @Input() currentWishlist: Wishlist; + + addToCartForm: FormGroup; + product$: Observable; + + ngOnInit() { + this.initForm(); + } + + ngOnChanges(s: SimpleChanges) { + if (s.wishlistItemData) { + this.loadProductDetails(); + } + } + + /** init form in the beginning */ + private initForm() { + this.addToCartForm = new FormGroup({ + quantity: new FormControl(1), + }); + } + + addToCart(sku: string) { + this.productFacade.addProductToBasket(sku, Number(this.addToCartForm.get('quantity').value)); + } + + moveItemToOtherWishlist(sku: string, wishlistMoveData: { id: string; title: string }) { + if (wishlistMoveData.id) { + this.wishlistsFacade.moveItemToWishlist(this.currentWishlist.id, wishlistMoveData.id, sku); + } else { + this.wishlistsFacade.moveItemToNewWishlist(this.currentWishlist.id, wishlistMoveData.title, sku); + } + } + + removeProductFromWishlist(sku: string) { + this.wishlistsFacade.removeProductFromWishlist(this.currentWishlist.id, sku); + } + + /**if the wishlistItem is loaded, get product details*/ + private loadProductDetails() { + if (!this.product$) { + this.product$ = this.productFacade.product$( + this.wishlistItemData.sku, + AccountWishlistDetailLineItemComponent.REQUIRED_COMPLETENESS_LEVEL + ); + } + } +} diff --git a/src/app/extensions/wishlists/pages/account-wishlist-detail/account-wishlist-detail-page.component.html b/src/app/extensions/wishlists/pages/account-wishlist-detail/account-wishlist-detail-page.component.html new file mode 100644 index 0000000000..9ff62e5966 --- /dev/null +++ b/src/app/extensions/wishlists/pages/account-wishlist-detail/account-wishlist-detail-page.component.html @@ -0,0 +1,64 @@ + + + + + + + + + + + +

{{ wishlist?.title }}

+ +

+ {{ 'account.wishlist.header.preferred_wishlist' | translate }} +

+ +
+ +
+
{{ 'account.wishlist.table.header.item' | translate }}
+
+ {{ 'account.wishlist.table.header.price' | translate }} +
+
+
+ +
+ +
+
+
+
+
+ + +

{{ 'account.wishlist.no_entries' | translate }}

+
+ + +
+ +{{ 'account.wishlist.searchform.return.link' | translate }} + diff --git a/src/app/extensions/wishlists/pages/account-wishlist-detail/account-wishlist-detail-page.component.spec.ts b/src/app/extensions/wishlists/pages/account-wishlist-detail/account-wishlist-detail-page.component.spec.ts new file mode 100644 index 0000000000..9284527e26 --- /dev/null +++ b/src/app/extensions/wishlists/pages/account-wishlist-detail/account-wishlist-detail-page.component.spec.ts @@ -0,0 +1,53 @@ +import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; + +import { coreReducers } from 'ish-core/store/core-store.module'; +import { ngrxTesting } from 'ish-core/utils/dev/ngrx-testing'; +import { ErrorMessageComponent } from 'ish-shared/components/common/error-message/error-message.component'; +import { LoadingComponent } from 'ish-shared/components/common/loading/loading.component'; + +import { WishlistPreferencesDialogComponent } from '../../shared/wishlists/wishlist-preferences-dialog/wishlist-preferences-dialog.component'; + +import { AccountWishlistDetailLineItemComponent } from './account-wishlist-detail-line-item/account-wishlist-detail-line-item.component'; +import { AccountWishlistDetailPageComponent } from './account-wishlist-detail-page.component'; + +describe('Account Wishlist Detail Page Component', () => { + let component: AccountWishlistDetailPageComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + NgbPopoverModule, + RouterTestingModule, + TranslateModule.forRoot(), + ngrxTesting({ reducers: coreReducers }), + ], + declarations: [ + AccountWishlistDetailPageComponent, + MockComponent(AccountWishlistDetailLineItemComponent), + MockComponent(ErrorMessageComponent), + MockComponent(FaIconComponent), + MockComponent(LoadingComponent), + MockComponent(WishlistPreferencesDialogComponent), + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AccountWishlistDetailPageComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); +}); diff --git a/src/app/extensions/wishlists/pages/account-wishlist-detail/account-wishlist-detail-page.component.ts b/src/app/extensions/wishlists/pages/account-wishlist-detail/account-wishlist-detail-page.component.ts new file mode 100644 index 0000000000..dccce9d340 --- /dev/null +++ b/src/app/extensions/wishlists/pages/account-wishlist-detail/account-wishlist-detail-page.component.ts @@ -0,0 +1,39 @@ +import { ChangeDetectionStrategy, Component, OnDestroy, OnInit } from '@angular/core'; +import { Observable, Subject } from 'rxjs'; + +import { HttpError } from 'ish-core/models/http-error/http-error.model'; + +import { WishlistsFacade } from '../../facades/wishlists.facade'; +import { Wishlist } from '../../models/wishlist/wishlist.model'; + +@Component({ + selector: 'ish-account-wishlist-detail-page', + templateUrl: './account-wishlist-detail-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AccountWishlistDetailPageComponent implements OnInit, OnDestroy { + wishlist$: Observable; + wishlistError$: Observable; + wishlistLoading$: Observable; + + private destroy$ = new Subject(); + + constructor(private wishlistsFacade: WishlistsFacade) {} + + ngOnInit() { + this.wishlist$ = this.wishlistsFacade.currentWishlist$; + this.wishlistLoading$ = this.wishlistsFacade.wishlistLoading$; + this.wishlistError$ = this.wishlistsFacade.wishlistError$; + } + + ngOnDestroy() { + this.destroy$.next(); + } + + editPreferences(wishlist: Wishlist, wishlistName: string) { + this.wishlistsFacade.updateWishlist({ + ...wishlist, + id: wishlistName, + }); + } +} diff --git a/src/app/extensions/wishlists/pages/account-wishlist-detail/account-wishlist-detail-page.module.ts b/src/app/extensions/wishlists/pages/account-wishlist-detail/account-wishlist-detail-page.module.ts new file mode 100644 index 0000000000..88517da2b8 --- /dev/null +++ b/src/app/extensions/wishlists/pages/account-wishlist-detail/account-wishlist-detail-page.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { SharedModule } from 'ish-shared/shared.module'; + +import { WishlistsModule } from '../../wishlists.module'; + +import { AccountWishlistDetailLineItemComponent } from './account-wishlist-detail-line-item/account-wishlist-detail-line-item.component'; +import { AccountWishlistDetailPageComponent } from './account-wishlist-detail-page.component'; + +const accountWishlistDetailPageRoutes: Routes = [ + { + path: '', + component: AccountWishlistDetailPageComponent, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(accountWishlistDetailPageRoutes), SharedModule, WishlistsModule], + declarations: [AccountWishlistDetailLineItemComponent, AccountWishlistDetailPageComponent], +}) +export class AccountWishlistDetailPageModule {} diff --git a/src/app/extensions/wishlists/pages/account-wishlist/account-wishlist-list/account-wishlist-list.component.html b/src/app/extensions/wishlists/pages/account-wishlist/account-wishlist-list/account-wishlist-list.component.html new file mode 100644 index 0000000000..9714f5ae7f --- /dev/null +++ b/src/app/extensions/wishlists/pages/account-wishlist/account-wishlist-list/account-wishlist-list.component.html @@ -0,0 +1,45 @@ +
+ + + + + + +
+ +

{{ 'account.wishlists.no_wishlists' | translate }}

+
+ +
+
+ {{ + wishlist.title + }}{{ + 'account.wishlists.table.preferred' | translate + }} +
+
+ {{ wishlist.itemsCount | i18nPlural: ('account.wishlists.items' | translate) || { other: '#' } }} +
+
+ +
+
+
+ +{{ 'account.wishlists.delete_wishlist_dialog.are_you_sure_paragraph' | translate }} diff --git a/src/app/extensions/wishlists/pages/account-wishlist/account-wishlist-list/account-wishlist-list.component.spec.ts b/src/app/extensions/wishlists/pages/account-wishlist/account-wishlist-list/account-wishlist-list.component.spec.ts new file mode 100644 index 0000000000..6ee1377c55 --- /dev/null +++ b/src/app/extensions/wishlists/pages/account-wishlist/account-wishlist-list/account-wishlist-list.component.spec.ts @@ -0,0 +1,43 @@ +import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; +import { spy, verify } from 'ts-mockito'; + +import { ModalDialogComponent } from 'ish-shared/components/common/modal-dialog/modal-dialog.component'; + +import { AccountWishlistListComponent } from './account-wishlist-list.component'; + +describe('Account Wishlist List Component', () => { + let component: AccountWishlistListComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [AccountWishlistListComponent, MockComponent(FaIconComponent), MockComponent(ModalDialogComponent)], + imports: [RouterTestingModule, TranslateModule.forRoot()], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AccountWishlistListComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should emit delete id when delete is called', () => { + const emitter = spy(component.deleteWishlist); + + component.delete('deleteId'); + + verify(emitter.emit('deleteId')).once(); + }); +}); diff --git a/src/app/extensions/wishlists/pages/account-wishlist/account-wishlist-list/account-wishlist-list.component.ts b/src/app/extensions/wishlists/pages/account-wishlist/account-wishlist-list/account-wishlist-list.component.ts new file mode 100644 index 0000000000..da7a62958f --- /dev/null +++ b/src/app/extensions/wishlists/pages/account-wishlist/account-wishlist-list/account-wishlist-list.component.ts @@ -0,0 +1,56 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { take } from 'rxjs/operators'; + +import { ModalDialogComponent } from 'ish-shared/components/common/modal-dialog/modal-dialog.component'; + +import { Wishlist } from '../../../models/wishlist/wishlist.model'; + +/** + * The Account Wishlist List Component show the customer an overview list over his wishlists. + * + * @example + * + */ +@Component({ + selector: 'ish-account-wishlist-list', + templateUrl: './account-wishlist-list.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AccountWishlistListComponent implements OnChanges { + /** + * The list of wishlists of the customer. + */ + @Input() wishlists: Wishlist[]; + /** + * Emits the id of the wishlist, which is to be deleted. + */ + @Output() deleteWishlist = new EventEmitter(); + + /** The header text of the delete modal. */ + deleteHeader: string; + preferredWishlist: Wishlist; + + constructor(private translate: TranslateService) {} + + ngOnChanges() { + // determine preferred wishlist + this.preferredWishlist = + this.wishlists && this.wishlists.length ? this.wishlists.find(wishlist => wishlist.preferred) : undefined; + } + + /** Emits the id of the wishlist to delete. */ + delete(wishlistId: string) { + this.deleteWishlist.emit(wishlistId); + } + + /** Determine the heading of the delete modal and opens the modal. */ + openDeleteConfirmationDialog(wishlist: Wishlist, modal: ModalDialogComponent) { + this.translate + .get('account.wishlists.delete_wishlist_dialog.header', { 0: wishlist.title }) + .pipe(take(1)) + .subscribe(res => (modal.options.titleText = res)); + + modal.show(wishlist.id); + } +} diff --git a/src/app/extensions/wishlists/pages/account-wishlist/account-wishlist-page.component.html b/src/app/extensions/wishlists/pages/account-wishlist/account-wishlist-page.component.html new file mode 100644 index 0000000000..1996f342dc --- /dev/null +++ b/src/app/extensions/wishlists/pages/account-wishlist/account-wishlist-page.component.html @@ -0,0 +1,36 @@ + + + + + diff --git a/src/app/extensions/wishlists/pages/account-wishlist/account-wishlist-page.component.spec.ts b/src/app/extensions/wishlists/pages/account-wishlist/account-wishlist-page.component.spec.ts new file mode 100644 index 0000000000..191a721d2c --- /dev/null +++ b/src/app/extensions/wishlists/pages/account-wishlist/account-wishlist-page.component.spec.ts @@ -0,0 +1,50 @@ +import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; +import { instance, mock } 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 { WishlistsFacade } from '../../facades/wishlists.facade'; +import { WishlistPreferencesDialogComponent } from '../../shared/wishlists/wishlist-preferences-dialog/wishlist-preferences-dialog.component'; + +import { AccountWishlistListComponent } from './account-wishlist-list/account-wishlist-list.component'; +import { AccountWishlistPageComponent } from './account-wishlist-page.component'; + +describe('Account Wishlist Page Component', () => { + let component: AccountWishlistPageComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async(() => { + const wishlistsFacade = mock(WishlistsFacade); + + TestBed.configureTestingModule({ + imports: [NgbPopoverModule, TranslateModule.forRoot()], + declarations: [ + AccountWishlistPageComponent, + MockComponent(AccountWishlistListComponent), + MockComponent(ErrorMessageComponent), + MockComponent(FaIconComponent), + MockComponent(LoadingComponent), + MockComponent(WishlistPreferencesDialogComponent), + ], + providers: [{ provide: WishlistsFacade, useFactory: () => instance(wishlistsFacade) }], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(AccountWishlistPageComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); +}); diff --git a/src/app/extensions/wishlists/pages/account-wishlist/account-wishlist-page.component.ts b/src/app/extensions/wishlists/pages/account-wishlist/account-wishlist-page.component.ts new file mode 100644 index 0000000000..91bb3e07d2 --- /dev/null +++ b/src/app/extensions/wishlists/pages/account-wishlist/account-wishlist-page.component.ts @@ -0,0 +1,45 @@ +import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; + +import { HttpError } from 'ish-core/models/http-error/http-error.model'; + +import { WishlistsFacade } from '../../facades/wishlists.facade'; +import { Wishlist, WishlistHeader } from '../../models/wishlist/wishlist.model'; + +@Component({ + selector: 'ish-account-wishlist-page', + templateUrl: './account-wishlist-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AccountWishlistPageComponent implements OnInit { + /** + * The list of wishlists of the customer. + */ + wishlists$: Observable; + /** + * Indicator for loading state of wishlists + */ + wishlistLoading$: Observable; + /** + * Error state in case of an error during creation of a new wishlist. + */ + wishlistError$: Observable; + + constructor(private wishlistsFacade: WishlistsFacade) {} + + ngOnInit() { + this.wishlists$ = this.wishlistsFacade.wishlists$; + this.wishlistLoading$ = this.wishlistsFacade.wishlistLoading$; + this.wishlistError$ = this.wishlistsFacade.wishlistError$; + } + + /** dispatch delete request */ + deleteWishlist(id: string) { + this.wishlistsFacade.deleteWishlist(id); + } + + /** dispatch creation request */ + addWishlist(wishlist: WishlistHeader) { + this.wishlistsFacade.addWishlist(wishlist); + } +} diff --git a/src/app/extensions/wishlists/pages/account-wishlist/account-wishlist-page.module.ts b/src/app/extensions/wishlists/pages/account-wishlist/account-wishlist-page.module.ts new file mode 100644 index 0000000000..34a86b2ea4 --- /dev/null +++ b/src/app/extensions/wishlists/pages/account-wishlist/account-wishlist-page.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +import { SharedModule } from 'ish-shared/shared.module'; + +import { WishlistsModule } from '../../wishlists.module'; + +import { AccountWishlistListComponent } from './account-wishlist-list/account-wishlist-list.component'; +import { AccountWishlistPageComponent } from './account-wishlist-page.component'; + +const accountWishlistPageRoutes: Routes = [ + { + path: '', + component: AccountWishlistPageComponent, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(accountWishlistPageRoutes), SharedModule, WishlistsModule], + declarations: [AccountWishlistListComponent, AccountWishlistPageComponent], +}) +export class AccountWishlistPageModule {} diff --git a/src/app/extensions/wishlists/pages/wishlists-routing.module.ts b/src/app/extensions/wishlists/pages/wishlists-routing.module.ts new file mode 100644 index 0000000000..27fcf41ab7 --- /dev/null +++ b/src/app/extensions/wishlists/pages/wishlists-routing.module.ts @@ -0,0 +1,30 @@ +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-wishlist/account-wishlist-page.module').then(m => m.AccountWishlistPageModule), + canActivate: [FeatureToggleGuard, AuthGuard], + data: { feature: 'wishlists', breadcrumbData: [{ key: 'account.wishlists.breadcrumb_link' }] }, + }, + { + path: ':wishlistName', + loadChildren: () => + import('./account-wishlist-detail/account-wishlist-detail-page.module').then( + m => m.AccountWishlistDetailPageModule + ), + canActivate: [FeatureToggleGuard, AuthGuard], + data: { feature: 'wishlists' }, + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class WishlistsRoutingModule {} diff --git a/src/app/extensions/wishlists/services/wishlist/wishlist.service.spec.ts b/src/app/extensions/wishlists/services/wishlist/wishlist.service.spec.ts new file mode 100644 index 0000000000..da5abf275f --- /dev/null +++ b/src/app/extensions/wishlists/services/wishlist/wishlist.service.spec.ts @@ -0,0 +1,119 @@ +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { anything, instance, mock, verify, when } from 'ts-mockito'; + +import { ApiService } from 'ish-core/services/api/api.service'; + +import { WishlistData } from '../../models/wishlist/wishlist.interface'; +import { Wishlist, WishlistHeader } from '../../models/wishlist/wishlist.model'; + +import { WishlistService } from './wishlist.service'; + +describe('Wishlist Service', () => { + let apiServiceMock: ApiService; + let wishlistService: WishlistService; + + beforeEach(() => { + apiServiceMock = mock(ApiService); + TestBed.configureTestingModule({ + providers: [{ provide: ApiService, useFactory: () => instance(apiServiceMock) }], + }); + wishlistService = TestBed.get(WishlistService); + }); + + it('should be created', () => { + expect(wishlistService).toBeTruthy(); + }); + + it("should get wishlists when 'getWishlists' is called", done => { + when(apiServiceMock.get(`customers/-/wishlists`)).thenReturn(of({ elements: [{ uri: 'any/wishlists/1234' }] })); + when(apiServiceMock.get(`customers/-/wishlists/1234`)).thenReturn(of({ id: '1234', preferred: true })); + + wishlistService.getWishlists().subscribe(data => { + verify(apiServiceMock.get(`customers/-/wishlists`)).once(); + verify(apiServiceMock.get(`customers/-/wishlists/1234`)).once(); + expect(data).toMatchInlineSnapshot(` + Array [ + Object { + "id": "1234", + "items": Array [], + "itemsCount": 0, + "preferred": true, + "public": undefined, + "title": undefined, + }, + ] + `); + done(); + }); + }); + + it("should create a wishlist when 'createWishlist' is called", done => { + const wishlistId = '1234'; + const wishlistHeader: WishlistHeader = { title: 'wishlist title', preferred: false }; + when(apiServiceMock.post(`customers/-/wishlists`, anything())).thenReturn( + of({ title: wishlistId } as WishlistData) + ); + + wishlistService.createWishlist(wishlistHeader).subscribe(data => { + expect(wishlistId).toEqual(data.id); + verify(apiServiceMock.post(`customers/-/wishlists`, anything())).once(); + done(); + }); + }); + + it("should delete a wishlist when 'deleteWishlist' is called", done => { + const wishlistId = '1234'; + + when(apiServiceMock.delete(`customers/-/wishlists/${wishlistId}`)).thenReturn(of({})); + + wishlistService.deleteWishlist(wishlistId).subscribe(() => { + verify(apiServiceMock.delete(`customers/-/wishlists/${wishlistId}`)).once(); + done(); + }); + }); + + it("should update a wishlist when 'updateWishlist' is called", done => { + const wishlist: Wishlist = { id: '1234', title: 'wishlist title', preferred: false }; + + when(apiServiceMock.put(`customers/-/wishlists/${wishlist.id}`, anything())).thenReturn(of({ wishlist })); + + wishlistService.updateWishlist(wishlist).subscribe(data => { + expect(wishlist.id).toEqual(data.id); + verify(apiServiceMock.put(`customers/-/wishlists/${wishlist.id}`, anything())).once(); + done(); + }); + }); + + it("should add a product to a wishlist when 'addProductToWishlist' is called", done => { + const wishlistId = '1234'; + const sku = 'abcd'; + + when(apiServiceMock.post(`customers/-/wishlists/${wishlistId}/products/${sku}`, anything())).thenReturn(of({})); + when(apiServiceMock.get(`customers/-/wishlists/${wishlistId}`)).thenReturn( + of({ title: 'wishlist title' } as WishlistData) + ); + + wishlistService.addProductToWishlist(wishlistId, sku).subscribe(() => { + verify(apiServiceMock.post(`customers/-/wishlists/${wishlistId}/products/${sku}`, anything())).once(); + verify(apiServiceMock.get(`customers/-/wishlists/${wishlistId}`)).once(); + done(); + }); + }); + + it("should remove a product from a wishlist when 'removeProductToWishlist' is called", done => { + const wishlistId = '1234'; + const sku = 'abcd'; + + when(apiServiceMock.delete(`customers/-/wishlists/${wishlistId}/products/${sku}`)).thenReturn(of({})); + when(apiServiceMock.get(`customers/-/wishlists/${wishlistId}`)).thenReturn( + of({ title: 'wishlist title' } as WishlistData) + ); + + wishlistService.removeProductFromWishlist(wishlistId, sku).subscribe(() => { + verify(apiServiceMock.delete(`customers/-/wishlists/${wishlistId}/products/${sku}`)).once(); + verify(apiServiceMock.get(`customers/-/wishlists/${wishlistId}`)).once(); + done(); + }); + }); +}); diff --git a/src/app/extensions/wishlists/services/wishlist/wishlist.service.ts b/src/app/extensions/wishlists/services/wishlist/wishlist.service.ts new file mode 100644 index 0000000000..acf0935098 --- /dev/null +++ b/src/app/extensions/wishlists/services/wishlist/wishlist.service.ts @@ -0,0 +1,114 @@ +import { Injectable } from '@angular/core'; +import { Observable, forkJoin, throwError } from 'rxjs'; +import { concatMap, defaultIfEmpty, map, switchMap } from 'rxjs/operators'; + +import { ApiService, unpackEnvelope } from 'ish-core/services/api/api.service'; + +import { WishlistData } from '../../models/wishlist/wishlist.interface'; +import { WishlistMapper } from '../../models/wishlist/wishlist.mapper'; +import { Wishlist, WishlistHeader } from '../../models/wishlist/wishlist.model'; + +@Injectable({ providedIn: 'root' }) +export class WishlistService { + constructor(private apiService: ApiService, private wishlistMapper: WishlistMapper) {} + + /** + * Gets a list of wishlists for the current user. + * @returns The customer's wishlists. + */ + getWishlists(): Observable { + return this.apiService.get(`customers/-/wishlists`).pipe( + unpackEnvelope(), + map(wishlistData => wishlistData.map(this.wishlistMapper.fromDataToIds)), + map(wishlistData => wishlistData.map(wishlist => this.getWishlist(wishlist.id))), + // tslint:disable-next-line:no-unnecessary-callback-wrapper + switchMap(obsArray => forkJoin(obsArray)), + defaultIfEmpty([]) + ); + } + + /** + * Gets a wishlist of the given id for the current user. + * @param wishlistId The wishlist id. + * @returns The wishlist. + */ + private getWishlist(wishlistId): Observable { + if (!wishlistId) { + return throwError('getWishlist() called without wishlistId'); + } + return this.apiService + .get(`customers/-/wishlists/${wishlistId}`) + .pipe(map(wishlistData => this.wishlistMapper.fromData(wishlistData, wishlistId))); + } + + /** + * Creates a wishlists for the current user. + * @param wishlistDetails The wishlist data. + * @returns The created wishlist. + */ + createWishlist(wishlistData: WishlistHeader): Observable { + return this.apiService + .post('customers/-/wishlists', wishlistData) + .pipe(map((response: WishlistData) => this.wishlistMapper.fromData(wishlistData, response.title))); + } + + /** + * Deletes a wishlist of the given id. + * @param wishlistId The wishlist id. + * @returns The wishlist. + */ + deleteWishlist(wishlistId: string): Observable { + if (!wishlistId) { + return throwError('deleteWishlist() called without wishlistId'); + } + return this.apiService.delete(`customers/-/wishlists/${wishlistId}`); + } + + /** + * Updates a wishlist of the given id. + * @param wishlist The wishlist to be updated. + * @returns The updated wishlist. + */ + updateWishlist(wishlist: Wishlist): Observable { + return this.apiService + .put(`customers/-/wishlists/${wishlist.id}`, wishlist) + .pipe(map((response: Wishlist) => this.wishlistMapper.fromUpdate(response, wishlist.id))); + } + + /** + * Adds a product to the wishlist with the given id and reloads the wishlist. + * @param wishlist Id The wishlist id. + * @param sku The product sku. + * @param quantity The product quantity (default = 1). + * @returns The changed wishlist. + */ + addProductToWishlist(wishlistId: string, sku: string, quantity = 1): Observable { + if (!wishlistId) { + return throwError('addProductToWishlist() called without wishlistId'); + } + if (!sku) { + return throwError('addProductToWishlist() called without sku'); + } + return this.apiService + .post(`customers/-/wishlists/${wishlistId}/products/${sku}`, { quantity }) + .pipe(concatMap(() => this.getWishlist(wishlistId))); + } + + /** + * Removes a product from the wishlist with the given id. Returns an error observable if parameters are falsy. + * @param wishlist Id The wishlist id. + * @param sku The product sku. + * @returns The changed wishlist. + */ + removeProductFromWishlist(wishlistId: string, sku: string): Observable { + if (!wishlistId) { + return throwError('removeProductFromWishlist() called without wishlistId'); + } + if (!sku) { + return throwError('removeProductFromWishlist() called without sku'); + } + return this.apiService + .delete(`customers/-/wishlists/${wishlistId}/products/${sku}`) + .pipe(concatMap(() => this.getWishlist(wishlistId))); + } +} diff --git a/src/app/extensions/wishlists/shared/product/product-add-to-wishlist/product-add-to-wishlist.component.html b/src/app/extensions/wishlists/shared/product/product-add-to-wishlist/product-add-to-wishlist.component.html new file mode 100644 index 0000000000..5be95baefa --- /dev/null +++ b/src/app/extensions/wishlists/shared/product/product-add-to-wishlist/product-add-to-wishlist.component.html @@ -0,0 +1,25 @@ + + + + diff --git a/src/app/extensions/wishlists/shared/product/product-add-to-wishlist/product-add-to-wishlist.component.spec.ts b/src/app/extensions/wishlists/shared/product/product-add-to-wishlist/product-add-to-wishlist.component.spec.ts new file mode 100644 index 0000000000..e064604a20 --- /dev/null +++ b/src/app/extensions/wishlists/shared/product/product-add-to-wishlist/product-add-to-wishlist.component.spec.ts @@ -0,0 +1,94 @@ +import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; +import { of } from 'rxjs'; +import { anyString, instance, mock, verify, when } from 'ts-mockito'; + +import { AccountFacade } from 'ish-core/facades/account.facade'; +import { Product } from 'ish-core/models/product/product.model'; + +import { WishlistsFacade } from '../../../facades/wishlists.facade'; +import { SelectWishlistModalComponent } from '../../wishlists/select-wishlist-modal/select-wishlist-modal.component'; + +import { ProductAddToWishlistComponent } from './product-add-to-wishlist.component'; + +describe('Product Add To Wishlist Component', () => { + let component: ProductAddToWishlistComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let wishlistFacadeMock: WishlistsFacade; + let accountFacadeMock: AccountFacade; + + const wishlistDetails = [ + { + title: 'testing wishlist', + type: 'WishList', + id: '.SKsEQAE4FIAAAFuNiUBWx0d', + itemsCount: 0, + preferred: true, + public: false, + }, + { + title: 'testing wishlist 2', + type: 'WishList', + id: '.AsdHS18FIAAAFuNiUBWx0d', + itemsCount: 0, + preferred: false, + public: false, + }, + { + title: 'new wishlist', + type: 'Wishlist', + id: 'new wishlist id', + itemsCount: 0, + preferred: false, + public: false, + }, + ]; + + beforeEach(async(() => { + wishlistFacadeMock = mock(WishlistsFacade); + accountFacadeMock = mock(AccountFacade); + + TestBed.configureTestingModule({ + declarations: [ + MockComponent(FaIconComponent), + MockComponent(SelectWishlistModalComponent), + ProductAddToWishlistComponent, + ], + imports: [RouterTestingModule, TranslateModule.forRoot()], + providers: [ + { provide: WishlistsFacade, useFactory: () => instance(wishlistFacadeMock) }, + { provide: AccountFacade, useFactory: () => instance(accountFacadeMock) }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ProductAddToWishlistComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + when(wishlistFacadeMock.wishlists$).thenReturn(of(wishlistDetails)); + component.product = { name: 'Test Product', sku: 'test sku' } as Product; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should call wishlistFacade to add product to wishlist', () => { + fixture.detectChanges(); + component.addProductToWishlist({ id: 'testid', title: 'Test Wishlist' }); + verify(wishlistFacadeMock.addProductToWishlist(anyString(), anyString())).once(); + }); + + it('should call wishlistFacade to add product to new wishlist', () => { + fixture.detectChanges(); + component.addProductToWishlist({ id: undefined, title: 'Test Wishlist' }); + verify(wishlistFacadeMock.addProductToNewWishlist(anyString(), anyString())).once(); + }); +}); diff --git a/src/app/extensions/wishlists/shared/product/product-add-to-wishlist/product-add-to-wishlist.component.ts b/src/app/extensions/wishlists/shared/product/product-add-to-wishlist/product-add-to-wishlist.component.ts new file mode 100644 index 0000000000..919785b43c --- /dev/null +++ b/src/app/extensions/wishlists/shared/product/product-add-to-wishlist/product-add-to-wishlist.component.ts @@ -0,0 +1,53 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { Router } from '@angular/router'; +import { take } from 'rxjs/operators'; + +import { AccountFacade } from 'ish-core/facades/account.facade'; +import { Product } from 'ish-core/models/product/product.model'; + +import { WishlistsFacade } from '../../../facades/wishlists.facade'; +import { SelectWishlistModalComponent } from '../../wishlists/select-wishlist-modal/select-wishlist-modal.component'; + +@Component({ + selector: 'ish-product-add-to-wishlist', + templateUrl: './product-add-to-wishlist.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +/** + * The Product Add To Wishlist Component adds a product to a wishlist. + * + * @example + * + */ +export class ProductAddToWishlistComponent { + @Input() product: Product; + @Input() displayType?: 'icon' | 'link' | 'animated' = 'link'; + + constructor(private wishlistsFacade: WishlistsFacade, private accountFacade: AccountFacade, private router: Router) {} + + /** + * if the user is not logged in display login dialog, else open select wishlist dialog + */ + openModal(modal: SelectWishlistModalComponent) { + this.accountFacade.isLoggedIn$.pipe(take(1)).subscribe(isLoggedIn => { + if (isLoggedIn) { + modal.show(); + } else { + // stay on the same page after login + const queryParams = { returnUrl: this.router.routerState.snapshot.url, messageKey: 'wishlists' }; + this.router.navigate(['/login'], { queryParams }); + } + }); + } + + addProductToWishlist(wishlist: { id: string; title: string }) { + if (!wishlist.id) { + this.wishlistsFacade.addProductToNewWishlist(wishlist.title, this.product.sku); + } else { + this.wishlistsFacade.addProductToWishlist(wishlist.id, this.product.sku); + } + } +} diff --git a/src/app/extensions/wishlists/shared/wishlists/select-wishlist-modal/select-wishlist-modal.component.html b/src/app/extensions/wishlists/shared/wishlists/select-wishlist-modal/select-wishlist-modal.component.html new file mode 100644 index 0000000000..a4e1af55b4 --- /dev/null +++ b/src/app/extensions/wishlists/shared/wishlists/select-wishlist-modal/select-wishlist-modal.component.html @@ -0,0 +1,96 @@ + + + diff --git a/src/app/extensions/wishlists/shared/wishlists/select-wishlist-modal/select-wishlist-modal.component.spec.ts b/src/app/extensions/wishlists/shared/wishlists/select-wishlist-modal/select-wishlist-modal.component.spec.ts new file mode 100644 index 0000000000..ac70831455 --- /dev/null +++ b/src/app/extensions/wishlists/shared/wishlists/select-wishlist-modal/select-wishlist-modal.component.spec.ts @@ -0,0 +1,128 @@ +import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; +import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent, MockDirective } from 'ng-mocks'; +import { of } from 'rxjs'; +import { anything, capture, instance, mock, spy, verify, when } from 'ts-mockito'; + +import { ServerHtmlDirective } from 'ish-core/directives/server-html.directive'; +import { InputComponent } from 'ish-shared/forms/components/input/input.component'; + +import { WishlistsFacade } from '../../../facades/wishlists.facade'; + +import { SelectWishlistModalComponent } from './select-wishlist-modal.component'; + +describe('Select Wishlist Modal Component', () => { + let component: SelectWishlistModalComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + let wishlistFacadeMock: WishlistsFacade; + const wishlistDetails = { + title: 'testing wishlist', + type: 'WishList', + id: '.SKsEQAE4FIAAAFuNiUBWx0d', + itemsCount: 0, + preferred: true, + public: false, + }; + + beforeEach(async(() => { + wishlistFacadeMock = mock(WishlistsFacade); + + TestBed.configureTestingModule({ + declarations: [MockComponent(InputComponent), MockDirective(ServerHtmlDirective), SelectWishlistModalComponent], + imports: [NgbModalModule, ReactiveFormsModule, RouterTestingModule, TranslateModule.forRoot()], + providers: [{ provide: WishlistsFacade, useFactory: () => instance(wishlistFacadeMock) }], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SelectWishlistModalComponent); + + component = fixture.componentInstance; + element = fixture.nativeElement; + when(wishlistFacadeMock.currentWishlist$).thenReturn(of(wishlistDetails)); + when(wishlistFacadeMock.preferredWishlist$).thenReturn(of()); + when(wishlistFacadeMock.wishlists$).thenReturn(of([wishlistDetails])); + + fixture.detectChanges(); + component.show(); + + component.wishlistOptions = [{ value: 'wishlist', label: 'Wishlist' }]; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should emit correct object on form submit with known wishlist', () => { + const emitter = spy(component.submitEmitter); + component.updateWishlistForm.patchValue({ wishlist: 'wishlist' }); + + component.submitForm(); + verify(emitter.emit(anything())).once(); + const [arg] = capture(emitter.emit).last(); + expect(arg).toEqual({ + id: 'wishlist', + title: 'Wishlist', + }); + }); + + it('should emit correct object on form submit with new wishlist', () => { + const emitter = spy(component.submitEmitter); + component.updateWishlistForm.patchValue({ wishlist: 'newList', newWishlist: 'New Wishlist Title' }); + + component.submitForm(); + verify(emitter.emit(anything())).once(); + const [arg] = capture(emitter.emit).last(); + expect(arg).toEqual({ + id: undefined, + title: 'New Wishlist Title', + }); + }); + + it('should switch modal contents after successful submit', () => { + component.updateWishlistForm.patchValue({ wishlist: 'wishlist' }); + + component.submitForm(); + expect(element.querySelector('form')).toBeFalsy(); + }); + + it('should ensure that newWishlist remove Validator after being deselected', () => { + component.updateWishlistForm.patchValue({ wishlist: 'wishlist', newWishlist: '' }); + expect(component.updateWishlistForm.get('newWishlist').validator).toBeNull(); + }); + + describe('selectedWishlistTitle', () => { + it('should return correct title of known wishlist', () => { + component.updateWishlistForm.patchValue({ wishlist: 'wishlist' }); + const title = component.selectedWishlistTitle; + expect(title).toBe('Wishlist'); + }); + + it('should return correct title of new wishlist', () => { + component.updateWishlistForm.patchValue({ wishlist: 'newList', newWishlist: 'New Wishlist Title' }); + const title = component.selectedWishlistTitle; + expect(title).toBe('New Wishlist Title'); + }); + }); + + describe('selectedWishlistRoute', () => { + it('should return correct route of known wishlist', () => { + component.updateWishlistForm.patchValue({ wishlist: 'wishlist' }); + const route = component.selectedWishlistRoute; + expect(route).toBe('route://account/wishlists/wishlist'); + }); + + it('should return correct route of new wishlist', () => { + component.updateWishlistForm.patchValue({ wishlist: 'newList', newWishlist: 'New Wishlist Title' }); + component.idAfterCreate = 'idAfterCreate'; + const route = component.selectedWishlistRoute; + expect(route).toBe('route://account/wishlists/idAfterCreate'); + }); + }); +}); diff --git a/src/app/extensions/wishlists/shared/wishlists/select-wishlist-modal/select-wishlist-modal.component.ts b/src/app/extensions/wishlists/shared/wishlists/select-wishlist-modal/select-wishlist-modal.component.ts new file mode 100644 index 0000000000..965abdecdb --- /dev/null +++ b/src/app/extensions/wishlists/shared/wishlists/select-wishlist-modal/select-wishlist-modal.component.ts @@ -0,0 +1,236 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + TemplateRef, + ViewChild, +} from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateService } from '@ngx-translate/core'; +import { Subject } from 'rxjs'; +import { take, takeUntil } from 'rxjs/operators'; + +import { Product } from 'ish-core/models/product/product.model'; +import { SelectOption } from 'ish-shared/forms/components/select/select.component'; +import { markAsDirtyRecursive } from 'ish-shared/forms/utils/form-utils'; + +import { WishlistsFacade } from '../../../facades/wishlists.facade'; +import { Wishlist } from '../../../models/wishlist/wishlist.model'; + +/** + * The wishlist select modal displays a list of wishlists. The user can select one wishlist or enter a name for a new wishlist in order to add or move an item to a the selected wishlist. + */ +@Component({ + selector: 'ish-select-wishlist-modal', + templateUrl: './select-wishlist-modal.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SelectWishlistModalComponent implements OnInit, OnDestroy { + @Input() product: Product; + + /** + * changes the some logic and the translations keys between add or move a product (default: 'add') + */ + @Input() addMoveProduct: 'add' | 'move' = 'add'; + + /** + * submit successfull event + */ + @Output() submitEmitter = new EventEmitter<{ id: string; title: string }>(); + + preferredWishlist: Wishlist; + updateWishlistForm: FormGroup; + wishlistOptions: SelectOption[]; + + showForm: boolean; + newWishlistInitValue = ''; + + modal: NgbModalRef; + + idAfterCreate = ''; + private destroy$ = new Subject(); + + // tslint:disable-next-line:no-any + @ViewChild('modal', { static: false }) modalTemplate: TemplateRef; + + constructor( + private ngbModal: NgbModal, + private fb: FormBuilder, + private translate: TranslateService, + private wishlistsFacade: WishlistsFacade + ) {} + + ngOnInit() { + this.wishlistsFacade.preferredWishlist$.pipe(take(1)).subscribe(wishlist => (this.preferredWishlist = wishlist)); + this.determineSelectOptions(); + this.formInit(); + this.wishlistsFacade.currentWishlist$ + .pipe(takeUntil(this.destroy$)) + .subscribe(wishlist => (this.idAfterCreate = wishlist && wishlist.id)); + + this.translate + .get('account.wishlists.choose_wishlist.new_wishlist_name.initial_value') + .pipe(take(1)) + .subscribe(res => { + this.newWishlistInitValue = res; + this.setDefaultFormValues(); + }); + this.updateWishlistForm.valueChanges.subscribe(changes => { + if (changes.wishlist !== 'newList') { + this.updateWishlistForm.get('newWishlist').clearValidators(); + } else { + this.updateWishlistForm.get('newWishlist').setValidators(Validators.required); + } + this.updateWishlistForm.get('newWishlist').updateValueAndValidity({ emitEvent: false }); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + } + + private formInit() { + this.updateWishlistForm = this.fb.group({ + wishlist: [ + this.wishlistOptions && this.wishlistOptions.length > 0 ? this.wishlistOptions[0].value : 'newList', + Validators.required, + ], + newWishlist: [this.newWishlistInitValue, Validators.required], + }); + } + + private determineSelectOptions() { + let currentWishlist: Wishlist; + this.wishlistsFacade.currentWishlist$.pipe(take(1)).subscribe(w => (currentWishlist = w)); + this.wishlistsFacade.wishlists$.pipe(takeUntil(this.destroy$)).subscribe(wishlists => { + if (wishlists && wishlists.length > 0) { + this.wishlistOptions = wishlists.map(wishlist => ({ + value: wishlist.id, + label: wishlist.title, + })); + if (this.addMoveProduct === 'move' && currentWishlist) { + this.wishlistOptions = this.wishlistOptions.filter(option => option.value !== currentWishlist.id); + } + } else { + this.wishlistOptions = []; + } + this.setDefaultFormValues(); + this.addProductToPreferredWishlist(); + }); + } + + private setDefaultFormValues() { + if (this.showForm && !this.addProductToPreferredWishlist()) { + if (this.wishlistOptions && this.wishlistOptions.length > 0) { + if (this.preferredWishlist) { + this.updateWishlistForm.get('wishlist').setValue(this.preferredWishlist.id); + } else { + this.updateWishlistForm.get('wishlist').setValue(this.wishlistOptions[0].value); + } + } else { + this.updateWishlistForm.get('wishlist').setValue('newList'); + } + this.updateWishlistForm.get('newWishlist').setValue(this.newWishlistInitValue); + } + } + + /* don't show wishlist selection form but add a product immediately if there is a preferred wishlist */ + private addProductToPreferredWishlist(): boolean { + if (this.showForm && this.preferredWishlist && this.addMoveProduct === 'add') { + this.updateWishlistForm.get('wishlist').setValue(this.preferredWishlist.id); + this.submitForm(); + return true; + } + return false; + } + + /** emit results when the form is valid */ + submitForm() { + if (this.updateWishlistForm.valid) { + const wishlistId = this.updateWishlistForm.get('wishlist').value; + this.submitEmitter.emit({ + id: wishlistId !== 'newList' ? wishlistId : undefined, + title: + wishlistId !== 'newList' + ? this.wishlistOptions.find(option => option.value === wishlistId).label + : this.updateWishlistForm.get('newWishlist').value, + }); + this.showForm = false; + } else { + markAsDirtyRecursive(this.updateWishlistForm); + } + } + + /** close modal */ + hide() { + this.modal.close(); + } + + /** open modal */ + show() { + this.showForm = true; + this.setDefaultFormValues(); + this.modal = this.ngbModal.open(this.modalTemplate); + } + + /** + * Callback function to hide modal dialog (used with ishServerHtml). - is needed for closing the dialog after the user clicks a message link + */ + get callbackHideDialogModal() { + return () => { + this.hide(); + }; + } + + /* * returns the title of the selected wishlist */ + get selectedWishlistTitle(): string { + const selectedValue = this.updateWishlistForm.get('wishlist').value; + if (selectedValue === 'newList') { + return this.updateWishlistForm.get('newWishlist').value; + } else { + return this.wishlistOptions.find(wishlist => wishlist.value === selectedValue).label; + } + } + + /** returns the route to the selected wishlist */ + get selectedWishlistRoute(): string { + const selectedValue = this.updateWishlistForm.get('wishlist').value; + if (selectedValue === 'newList') { + return `route://account/wishlists/${this.idAfterCreate}`; + } else { + return `route://account/wishlists/${selectedValue}`; + } + } + + /** activates the input field to create a new wishlist */ + get newWishlistDisabled() { + const selectedWishlist = this.updateWishlistForm.get('wishlist').value; + return selectedWishlist !== 'newList'; + } + + /** translation key for the modal header */ + get headerTranslationKey() { + return this.addMoveProduct === 'add' + ? 'product.add_to_wishlist.link' + : 'account.wishlist.table.options.move_to_another_wishlist'; + } + + /** translation key for the submit button */ + get submitButtonTranslationKey() { + return this.addMoveProduct === 'add' + ? 'account.wishlists.add_to_wishlist.add_button.text' + : 'account.wishlists.move_wishlist_item.move_button.text'; + } + + /** translation key for the success text */ + get successTranslationKey() { + return this.addMoveProduct === 'add' + ? 'account.wishlists.add_to_wishlist.confirmation' + : 'account.wishlists.move_wishlist_item.confirmation'; + } +} diff --git a/src/app/extensions/wishlists/shared/wishlists/wishlist-preferences-dialog/wishlist-preferences-dialog.component.html b/src/app/extensions/wishlists/shared/wishlists/wishlist-preferences-dialog/wishlist-preferences-dialog.component.html new file mode 100644 index 0000000000..9556ec5555 --- /dev/null +++ b/src/app/extensions/wishlists/shared/wishlists/wishlist-preferences-dialog/wishlist-preferences-dialog.component.html @@ -0,0 +1,69 @@ + + + diff --git a/src/app/extensions/wishlists/shared/wishlists/wishlist-preferences-dialog/wishlist-preferences-dialog.component.spec.ts b/src/app/extensions/wishlists/shared/wishlists/wishlist-preferences-dialog/wishlist-preferences-dialog.component.spec.ts new file mode 100644 index 0000000000..d7079c57e2 --- /dev/null +++ b/src/app/extensions/wishlists/shared/wishlists/wishlist-preferences-dialog/wishlist-preferences-dialog.component.spec.ts @@ -0,0 +1,89 @@ +import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { NgbModalModule, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; +import { spy, verify } from 'ts-mockito'; + +import { CheckboxComponent } from 'ish-shared/forms/components/checkbox/checkbox.component'; +import { InputComponent } from 'ish-shared/forms/components/input/input.component'; + +import { WishlistPreferencesDialogComponent } from './wishlist-preferences-dialog.component'; + +describe('Wishlist Preferences Dialog Component', () => { + let component: WishlistPreferencesDialogComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + const wishlist = { + title: 'testing wishlist', + type: 'WishList', + id: '.SKsEQAE4FIAAAFuNiUBWx0d', + itemsCount: 0, + preferred: true, + public: false, + }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + MockComponent(CheckboxComponent), + MockComponent(FaIconComponent), + MockComponent(InputComponent), + WishlistPreferencesDialogComponent, + ], + imports: [NgbModalModule, NgbPopoverModule, ReactiveFormsModule, TranslateModule.forRoot()], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(WishlistPreferencesDialogComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); + + it('should emit new wishlist data when submit form was called and the form was valid', done => { + fixture.detectChanges(); + component.wishListForm.setValue({ + title: 'test wishlist', + preferred: true, + }); + + component.submit.subscribe(emit => { + expect(emit).toEqual({ + id: 'test wishlist', + title: 'test wishlist', + preferred: true, + public: false, + }); + done(); + }); + + component.submitWishlistForm(); + }); + + it('should not emit new wishlist data when submit form was called and the form was invalid', () => { + component.ngOnChanges(); + fixture.detectChanges(); + const emitter = spy(component.submit); + component.submitWishlistForm(); + + verify(emitter.emit()).never(); + }); + + it('should fill form with wishlist data if formContent is passed', () => { + component.wishlist = wishlist; + component.ngOnChanges(); + fixture.detectChanges(); + + expect(component.wishListForm.value.title).toEqual(wishlist.title); + expect(component.wishListForm.value.preferred).toEqual(wishlist.preferred); + }); +}); diff --git a/src/app/extensions/wishlists/shared/wishlists/wishlist-preferences-dialog/wishlist-preferences-dialog.component.ts b/src/app/extensions/wishlists/shared/wishlists/wishlist-preferences-dialog/wishlist-preferences-dialog.component.ts new file mode 100644 index 0000000000..e2df05ab11 --- /dev/null +++ b/src/app/extensions/wishlists/shared/wishlists/wishlist-preferences-dialog/wishlist-preferences-dialog.component.ts @@ -0,0 +1,125 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnChanges, + Output, + TemplateRef, + ViewChild, +} from '@angular/core'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; + +import { markAsDirtyRecursive } from 'ish-shared/forms/utils/form-utils'; + +import { Wishlist } from '../../../models/wishlist/wishlist.model'; + +/** + * The Wishlist Preferences Dialog shows the modal to create/edit a wishlist. + * + * @example + * + + */ +@Component({ + selector: 'ish-wishlist-preferences-dialog', + templateUrl: './wishlist-preferences-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WishlistPreferencesDialogComponent implements OnChanges { + /** + * Predefined wishlist to fill the form with, if there is no wishlist a new wishlist will be created + */ + @Input() wishlist: Wishlist; + + /** + * Emits the data of the new wishlist to create. + */ + @Output() submit = new EventEmitter(); + + wishListForm: FormGroup; + submitted = false; + + /** + * A reference to the current modal. + */ + modal: NgbModalRef; + + // localization keys, default = for new + primaryButton = 'account.wishlists.new_wishlist_form.create_button.text'; + wishlistTitle = 'account.wishlists.choose_wishlist.new_wishlist_name.initial_value'; + modalHeader = 'account.wishlists.new_wishlist_dialog.header'; + + // tslint:disable-next-line:no-any + @ViewChild('modal', { static: false }) modalTemplate: TemplateRef; + + constructor(private fb: FormBuilder, private ngbModal: NgbModal) { + this.initForm(); + } + + ngOnChanges() { + this.patchForm(); + if (this.wishlist) { + this.primaryButton = 'account.wishlists.edit_wishlist_form.save_button.text'; + this.wishlistTitle = this.wishlist.title; + this.modalHeader = 'account.wishlist.list.edit'; + } + } + + initForm() { + this.wishListForm = this.fb.group({ + title: ['', [Validators.required, Validators.maxLength(35)]], + preferred: false, + }); + } + + patchForm() { + if (this.wishlist) { + this.wishListForm.setValue({ + title: this.wishlist.title, + preferred: this.wishlist.preferred, + }); + } + } + + /** Emits the wishlist data, when the form was valid. */ + submitWishlistForm() { + if (this.wishListForm.valid) { + this.submit.emit({ + id: !this.wishlist ? this.wishListForm.get('title').value : this.wishlistTitle, + preferred: this.wishListForm.get('preferred').value, + title: this.wishListForm.get('title').value, + public: false, + }); + + this.hide(); + } else { + this.submitted = true; + markAsDirtyRecursive(this.wishListForm); + } + } + + /** Opens the modal. */ + show() { + this.modal = this.ngbModal.open(this.modalTemplate); + } + + /** Close the modal. */ + hide() { + this.wishListForm.reset({ + title: '', + preferred: false, + }); + this.submitted = false; + if (this.modal) { + this.modal.close(); + } + } + + get formDisabled() { + return this.wishListForm.invalid && this.submitted; + } +} diff --git a/src/app/extensions/wishlists/shared/wishlists/wishlists-link/wishlists-link.component.html b/src/app/extensions/wishlists/shared/wishlists/wishlists-link/wishlists-link.component.html new file mode 100644 index 0000000000..d575d78611 --- /dev/null +++ b/src/app/extensions/wishlists/shared/wishlists/wishlists-link/wishlists-link.component.html @@ -0,0 +1,13 @@ + + + + {{ wishlist.itemsCount }} + {{ + 'account.wishlists.link' | translate + }} + diff --git a/src/app/extensions/wishlists/shared/wishlists/wishlists-link/wishlists-link.component.spec.ts b/src/app/extensions/wishlists/shared/wishlists/wishlists-link/wishlists-link.component.spec.ts new file mode 100644 index 0000000000..35185f6d32 --- /dev/null +++ b/src/app/extensions/wishlists/shared/wishlists/wishlists-link/wishlists-link.component.spec.ts @@ -0,0 +1,40 @@ +import { ComponentFixture, TestBed, async } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { TranslateModule } from '@ngx-translate/core'; +import { MockComponent } from 'ng-mocks'; +import { EMPTY } from 'rxjs'; +import { instance, mock, when } from 'ts-mockito'; + +import { WishlistsFacade } from '../../../facades/wishlists.facade'; + +import { WishlistsLinkComponent } from './wishlists-link.component'; + +describe('Wishlists Link Component', () => { + let component: WishlistsLinkComponent; + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(async(() => { + const wishlistFacadeMock = mock(WishlistsFacade); + when(wishlistFacadeMock.preferredWishlist$).thenReturn(EMPTY); + + TestBed.configureTestingModule({ + declarations: [MockComponent(FaIconComponent), WishlistsLinkComponent], + imports: [RouterTestingModule, TranslateModule.forRoot()], + providers: [{ provide: WishlistsFacade, useFactory: () => instance(wishlistFacadeMock) }], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(WishlistsLinkComponent); + component = fixture.componentInstance; + element = fixture.nativeElement; + }); + + it('should be created', () => { + expect(component).toBeTruthy(); + expect(element).toBeTruthy(); + expect(() => fixture.detectChanges()).not.toThrow(); + }); +}); diff --git a/src/app/extensions/wishlists/shared/wishlists/wishlists-link/wishlists-link.component.ts b/src/app/extensions/wishlists/shared/wishlists/wishlists-link/wishlists-link.component.ts new file mode 100644 index 0000000000..10c6832313 --- /dev/null +++ b/src/app/extensions/wishlists/shared/wishlists/wishlists-link/wishlists-link.component.ts @@ -0,0 +1,24 @@ +import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { WishlistsFacade } from '../../../facades/wishlists.facade'; +import { Wishlist } from '../../../models/wishlist/wishlist.model'; + +@Component({ + selector: 'ish-wishlists-link', + templateUrl: './wishlists-link.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WishlistsLinkComponent implements OnInit { + @Input() view: 'auto' | 'small' | 'full' = 'auto'; + preferredWishlist$: Observable; + routerLink$: Observable; + + constructor(private wishlistFacade: WishlistsFacade) {} + + ngOnInit() { + this.preferredWishlist$ = this.wishlistFacade.preferredWishlist$; + this.routerLink$ = this.preferredWishlist$.pipe(map(pref => `/account/wishlists${pref ? '/' + pref.id : '/'}`)); + } +} diff --git a/src/app/extensions/wishlists/store/wishlist/index.ts b/src/app/extensions/wishlists/store/wishlist/index.ts new file mode 100644 index 0000000000..4872c6d359 --- /dev/null +++ b/src/app/extensions/wishlists/store/wishlist/index.ts @@ -0,0 +1,4 @@ +// tslint:disable no-barrel-files +// API to access ngrx wishlists state +export * from './wishlist.actions'; +export * from './wishlist.selectors'; diff --git a/src/app/extensions/wishlists/store/wishlist/wishlist.actions.ts b/src/app/extensions/wishlists/store/wishlist/wishlist.actions.ts new file mode 100644 index 0000000000..bbec89a908 --- /dev/null +++ b/src/app/extensions/wishlists/store/wishlist/wishlist.actions.ts @@ -0,0 +1,171 @@ +import { Action } from '@ngrx/store'; + +import { HttpError } from 'ish-core/models/http-error/http-error.model'; + +import { Wishlist, WishlistHeader } from '../../models/wishlist/wishlist.model'; + +export enum WishlistsActionTypes { + LoadWishlists = '[Wishlists Internal] Load Wishlists', + LoadWishlistsSuccess = '[Wishlists API] Load Wishlists Success', + LoadWishlistsFail = '[Wishlists API] Load Wishlists Fail', + + CreateWishlist = '[Wishlists] Create Wishlist', + CreateWishlistSuccess = '[Wishlists API] Create Wishlist Success', + CreateWishlistFail = '[Wishlists API] Create Wishlist Fail', + + UpdateWishlist = '[Wishlists] Update Wishlist', + UpdateWishlistSuccess = '[Wishlists API] Update Wishlist Success', + UpdateWishlistFail = '[Wishlists API] Update Wishlist Fail', + + DeleteWishlist = '[Wishlists] Delete Wishlist', + DeleteWishlistSuccess = '[Wishlists API] Delete Wishlist Success', + DeleteWishlistFail = '[Wishlists API] Delete Wishlist Fail', + + AddProductToWishlist = '[Wishlists] Add Item to Wishlist', + AddProductToWishlistSuccess = '[Wishlists API] Add Item to Wishlist Success', + AddProductToWishlistFail = '[Wishlists API] Add Item to Wishlist Fail', + + AddProductToNewWishlist = '[Wishlists Internal] Add Product To New Wishlist', + + MoveItemToWishlist = '[Wishlists] Move Item to another Wishlist', + + RemoveItemFromWishlist = '[Wishlists] Remove Item from Wishlist', + RemoveItemFromWishlistSuccess = '[Wishlists API] Remove Item from Wishlist Success', + RemoveItemFromWishlistFail = '[Wishlists API] Remove Item from Wishlist Fail', + + SelectWishlist = '[Wishlists Internal] Select Wishlist', + ResetWishlistState = '[Wishlists Internal] Reset Wishlist State', +} + +export class LoadWishlists implements Action { + readonly type = WishlistsActionTypes.LoadWishlists; +} + +export class LoadWishlistsSuccess implements Action { + readonly type = WishlistsActionTypes.LoadWishlistsSuccess; + constructor(public payload: { wishlists: Wishlist[] }) {} +} + +export class LoadWishlistsFail implements Action { + readonly type = WishlistsActionTypes.LoadWishlistsFail; + constructor(public payload: { error: HttpError }) {} +} + +export class CreateWishlist implements Action { + readonly type = WishlistsActionTypes.CreateWishlist; + constructor(public payload: { wishlist: WishlistHeader }) {} +} + +export class CreateWishlistSuccess implements Action { + readonly type = WishlistsActionTypes.CreateWishlistSuccess; + constructor(public payload: { wishlist: Wishlist }) {} +} + +export class CreateWishlistFail implements Action { + readonly type = WishlistsActionTypes.CreateWishlistFail; + constructor(public payload: { error: HttpError }) {} +} + +export class UpdateWishlist implements Action { + readonly type = WishlistsActionTypes.UpdateWishlist; + constructor(public payload: { wishlist: Wishlist }) {} +} + +export class UpdateWishlistSuccess implements Action { + readonly type = WishlistsActionTypes.UpdateWishlistSuccess; + constructor(public payload: { wishlist: Wishlist }) {} +} + +export class UpdateWishlistFail implements Action { + readonly type = WishlistsActionTypes.UpdateWishlistFail; + constructor(public payload: { error: HttpError }) {} +} + +export class DeleteWishlist implements Action { + readonly type = WishlistsActionTypes.DeleteWishlist; + constructor(public payload: { wishlistId: string }) {} +} + +export class DeleteWishlistSuccess implements Action { + readonly type = WishlistsActionTypes.DeleteWishlistSuccess; + + constructor(public payload: { wishlistId: string }) {} +} + +export class DeleteWishlistFail implements Action { + readonly type = WishlistsActionTypes.DeleteWishlistFail; + constructor(public payload: { error: HttpError }) {} +} + +export class AddProductToWishlist implements Action { + readonly type = WishlistsActionTypes.AddProductToWishlist; + constructor(public payload: { wishlistId: string; sku: string; quantity?: number }) {} +} + +export class AddProductToWishlistSuccess implements Action { + readonly type = WishlistsActionTypes.AddProductToWishlistSuccess; + constructor(public payload: { wishlist: Wishlist }) {} +} + +export class AddProductToWishlistFail implements Action { + readonly type = WishlistsActionTypes.AddProductToWishlistFail; + constructor(public payload: { error: HttpError }) {} +} + +export class AddProductToNewWishlist implements Action { + readonly type = WishlistsActionTypes.AddProductToNewWishlist; + constructor(public payload: { title: string; sku: string }) {} +} + +export class MoveItemToWishlist implements Action { + readonly type = WishlistsActionTypes.MoveItemToWishlist; + constructor(public payload: { source: { id: string }; target: { id?: string; title?: string; sku: string } }) {} +} + +export class RemoveItemFromWishlist implements Action { + readonly type = WishlistsActionTypes.RemoveItemFromWishlist; + constructor(public payload: { wishlistId: string; sku: string }) {} +} + +export class RemoveItemFromWishlistSuccess implements Action { + readonly type = WishlistsActionTypes.RemoveItemFromWishlistSuccess; + constructor(public payload: { wishlist: Wishlist }) {} +} + +export class RemoveItemFromWishlistFail implements Action { + readonly type = WishlistsActionTypes.RemoveItemFromWishlistFail; + constructor(public payload: { error: HttpError }) {} +} + +export class SelectWishlist implements Action { + readonly type = WishlistsActionTypes.SelectWishlist; + constructor(public payload: { id: string }) {} +} + +export class ResetWishlistState implements Action { + readonly type = WishlistsActionTypes.ResetWishlistState; +} + +export type WishlistsAction = + | LoadWishlists + | LoadWishlistsSuccess + | LoadWishlistsFail + | CreateWishlist + | CreateWishlistSuccess + | CreateWishlistFail + | UpdateWishlist + | UpdateWishlistSuccess + | UpdateWishlistFail + | DeleteWishlist + | DeleteWishlistSuccess + | DeleteWishlistFail + | AddProductToWishlist + | AddProductToWishlistSuccess + | AddProductToWishlistFail + | AddProductToNewWishlist + | MoveItemToWishlist + | RemoveItemFromWishlist + | RemoveItemFromWishlistSuccess + | RemoveItemFromWishlistFail + | SelectWishlist + | ResetWishlistState; diff --git a/src/app/extensions/wishlists/store/wishlist/wishlist.effects.spec.ts b/src/app/extensions/wishlists/store/wishlist/wishlist.effects.spec.ts new file mode 100644 index 0000000000..e5764ccb3a --- /dev/null +++ b/src/app/extensions/wishlists/store/wishlist/wishlist.effects.spec.ts @@ -0,0 +1,562 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Store, combineReducers } from '@ngrx/store'; +import { cold, hot } from 'jest-marbles'; +import { RouteNavigation } from 'ngrx-router'; +import { of, throwError } from 'rxjs'; +import { anyNumber, anyString, anything, instance, mock, verify, when } from 'ts-mockito'; + +import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; +import { Customer } from 'ish-core/models/customer/customer.model'; +import { HttpError } from 'ish-core/models/http-error/http-error.model'; +import { checkoutReducers } from 'ish-core/store/checkout/checkout-store.module'; +import { ApplyConfiguration } from 'ish-core/store/configuration'; +import { configurationReducer } from 'ish-core/store/configuration/configuration.reducer'; +import { SuccessMessage } from 'ish-core/store/messages'; +import { shoppingReducers } from 'ish-core/store/shopping/shopping-store.module'; +import { LoginUserSuccess, LogoutUser } from 'ish-core/store/user'; +import { userReducer } from 'ish-core/store/user/user.reducer'; +import { ngrxTesting } from 'ish-core/utils/dev/ngrx-testing'; + +import { Wishlist } from '../../models/wishlist/wishlist.model'; +import { WishlistService } from '../../services/wishlist/wishlist.service'; +import { wishlistsReducers } from '../wishlists-store.module'; + +import { + AddProductToNewWishlist, + AddProductToWishlist, + AddProductToWishlistFail, + AddProductToWishlistSuccess, + CreateWishlist, + CreateWishlistFail, + CreateWishlistSuccess, + DeleteWishlist, + DeleteWishlistFail, + DeleteWishlistSuccess, + LoadWishlists, + LoadWishlistsFail, + LoadWishlistsSuccess, + MoveItemToWishlist, + RemoveItemFromWishlist, + RemoveItemFromWishlistFail, + RemoveItemFromWishlistSuccess, + ResetWishlistState, + SelectWishlist, + UpdateWishlist, + UpdateWishlistFail, + UpdateWishlistSuccess, + WishlistsActionTypes, +} from './wishlist.actions'; +import { WishlistEffects } from './wishlist.effects'; + +describe('Wishlist Effects', () => { + let actions$; + let wishlistServiceMock: WishlistService; + let effects: WishlistEffects; + let store$: Store<{}>; + + const customer = { customerNo: 'CID', type: 'SMBCustomer' } as Customer; + + const wishlists = [ + { + title: 'testing wishlist', + type: 'WishList', + id: '.SKsEQAE4FIAAAFuNiUBWx0d', + itemsCount: 0, + preferred: true, + public: false, + }, + { + title: 'testing wishlist 2', + type: 'WishList', + id: '.AsdHS18FIAAAFuNiUBWx0d', + itemsCount: 0, + preferred: false, + public: false, + }, + ]; + @Component({ template: 'dummy' }) + class DummyComponent {} + + beforeEach(() => { + wishlistServiceMock = mock(WishlistService); + + TestBed.configureTestingModule({ + declarations: [DummyComponent], + imports: [ + FeatureToggleModule, + ngrxTesting({ + reducers: { + wishlists: combineReducers(wishlistsReducers), + shopping: combineReducers(shoppingReducers), + checkout: combineReducers(checkoutReducers), + user: userReducer, + configuration: configurationReducer, + }, + }), + ], + providers: [ + WishlistEffects, + provideMockActions(() => actions$), + { provide: WishlistService, useFactory: () => instance(wishlistServiceMock) }, + ], + }); + + effects = TestBed.get(WishlistEffects); + store$ = TestBed.get(Store); + + store$.dispatch(new ApplyConfiguration({ features: ['wishlists'] })); + }); + + describe('loadWishlists$', () => { + beforeEach(() => { + store$.dispatch(new LoginUserSuccess({ customer })); + when(wishlistServiceMock.getWishlists()).thenReturn(of(wishlists)); + }); + + it('should call the wishlistService for loadWishlists', done => { + const action = new LoadWishlists(); + actions$ = of(action); + + effects.loadWishlists$.subscribe(() => { + verify(wishlistServiceMock.getWishlists()).once(); + done(); + }); + }); + + it('should map to actions of type LoadWishlistsSuccess', () => { + const action = new LoadWishlists(); + const completion = new LoadWishlistsSuccess({ + wishlists, + }); + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.loadWishlists$).toBeObservable(expected$); + }); + + it('should map failed calls to actions of type LoadWishlistsFail', () => { + const error = { message: 'invalid' } as HttpError; + when(wishlistServiceMock.getWishlists()).thenReturn(throwError(error)); + const action = new LoadWishlists(); + const completion = new LoadWishlistsFail({ + error, + }); + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.loadWishlists$).toBeObservable(expected$); + }); + }); + + describe('createWishlist$', () => { + const wishlistData = [ + { + title: 'testing wishlist', + id: '.SKsEQAE4FIAAAFuNiUBWx0d', + } as Wishlist, + ]; + const createWishlistData = { + title: 'testing wishlist', + preferred: true, + public: false, + }; + beforeEach(() => { + store$.dispatch(new LoginUserSuccess({ customer })); + when(wishlistServiceMock.createWishlist(anything())).thenReturn(of(wishlistData[0])); + }); + + it('should call the wishlistService for createWishlist', done => { + const action = new CreateWishlist({ wishlist: createWishlistData }); + actions$ = of(action); + + effects.createWishlist$.subscribe(() => { + verify(wishlistServiceMock.createWishlist(anything())).once(); + done(); + }); + }); + + it('should map to actions of type CreateWishlistSuccess and SuccessMessage', () => { + const action = new CreateWishlist({ wishlist: createWishlistData }); + const completion1 = new CreateWishlistSuccess({ + wishlist: wishlistData[0], + }); + const completion2 = new SuccessMessage({ + message: 'account.wishlists.new_wishlist.confirmation', + messageParams: { 0: createWishlistData.title }, + }); + actions$ = hot('-a----a----a', { a: action }); + const expected$ = cold('-(cd)-(cd)-(cd)', { c: completion1, d: completion2 }); + + expect(effects.createWishlist$).toBeObservable(expected$); + }); + it('should map failed calls to actions of type CreateWishlistFail', () => { + const error = { message: 'invalid' } as HttpError; + when(wishlistServiceMock.createWishlist(anything())).thenReturn(throwError(error)); + const action = new CreateWishlist({ wishlist: createWishlistData }); + const completion = new CreateWishlistFail({ + error, + }); + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.createWishlist$).toBeObservable(expected$); + }); + + it('should map to action of type LoadWishlists if the wishlist is created as preferred', () => { + const createdWishlist: Wishlist = { + id: '1234', + title: 'title', + preferred: true, + public: false, + }; + const action = new CreateWishlistSuccess({ wishlist: createdWishlist }); + const completion = new LoadWishlists(); + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.reloadWishlists$).toBeObservable(expected$); + }); + }); + + describe('deleteWishlist$', () => { + const id = 'id'; + beforeEach(() => { + store$.dispatch(new LoginUserSuccess({ customer })); + when(wishlistServiceMock.deleteWishlist(anyString())).thenReturn(of(undefined)); + }); + + it('should call the wishlistService for deleteWishlist', done => { + const action = new DeleteWishlist({ wishlistId: id }); + actions$ = of(action); + + effects.deleteWishlist$.subscribe(() => { + verify(wishlistServiceMock.deleteWishlist(id)).once(); + done(); + }); + }); + + it('should map to actions of type DeleteWishlistSuccess', () => { + const action = new DeleteWishlist({ wishlistId: id }); + const completion = new DeleteWishlistSuccess({ wishlistId: id }); + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.deleteWishlist$).toBeObservable(expected$); + }); + it('should map failed calls to actions of type DeleteWishlistFail', () => { + const error = { message: 'invalid' } as HttpError; + when(wishlistServiceMock.deleteWishlist(anyString())).thenReturn(throwError(error)); + const action = new DeleteWishlist({ wishlistId: id }); + const completion = new DeleteWishlistFail({ + error, + }); + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.deleteWishlist$).toBeObservable(expected$); + }); + }); + + describe('updateWishlist$', () => { + const wishlistDetailData = [ + { + title: 'testing wishlist', + id: '.SKsEQAE4FIAAAFuNiUBWx0d', + itemCount: 0, + preferred: true, + public: false, + }, + ]; + beforeEach(() => { + store$.dispatch(new LoginUserSuccess({ customer })); + when(wishlistServiceMock.updateWishlist(anything())).thenReturn(of(wishlistDetailData[0])); + }); + + it('should call the wishlistService for updateWishlist', done => { + const action = new UpdateWishlist({ wishlist: wishlistDetailData[0] }); + actions$ = of(action); + + effects.updateWishlist$.subscribe(() => { + verify(wishlistServiceMock.updateWishlist(anything())).once(); + done(); + }); + }); + + it('should map to actions of type UpdateWishlistSuccess', () => { + const action = new UpdateWishlist({ wishlist: wishlistDetailData[0] }); + const completion = new UpdateWishlistSuccess({ wishlist: wishlistDetailData[0] }); + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.updateWishlist$).toBeObservable(expected$); + }); + it('should map failed calls to actions of type UpdateWishlistFail', () => { + const error = { message: 'invalid' } as HttpError; + when(wishlistServiceMock.updateWishlist(anything())).thenReturn(throwError(error)); + const action = new UpdateWishlist({ wishlist: wishlistDetailData[0] }); + const completion = new UpdateWishlistFail({ + error, + }); + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.updateWishlist$).toBeObservable(expected$); + }); + + it('should map to action of type LoadWishlists if the wishlist is updated as preferred', () => { + const updatedWishlist: Wishlist = { + id: '1234', + title: 'title', + preferred: true, + public: false, + }; + const action = new UpdateWishlistSuccess({ wishlist: updatedWishlist }); + const completion = new LoadWishlists(); + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.reloadWishlists$).toBeObservable(expected$); + }); + }); + + describe('addProductToWishlist$', () => { + const payload = { + wishlistId: '.SKsEQAE4FIAAAFuNiUBWx0d', + sku: 'sku', + quantity: 2, + }; + + beforeEach(() => { + store$.dispatch(new LoginUserSuccess({ customer })); + when(wishlistServiceMock.addProductToWishlist(anyString(), anyString(), anyNumber())).thenReturn( + of(wishlists[0]) + ); + }); + + it('should call the wishlistService for addProductToWishlist', done => { + const action = new AddProductToWishlist(payload); + actions$ = of(action); + + effects.addProductToWishlist$.subscribe(() => { + verify(wishlistServiceMock.addProductToWishlist(payload.wishlistId, payload.sku, payload.quantity)).once(); + done(); + }); + }); + + it('should map to actions of type AddProductToWishlistSuccess', () => { + const action = new AddProductToWishlist(payload); + const completion = new AddProductToWishlistSuccess({ wishlist: wishlists[0] }); + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + expect(effects.addProductToWishlist$).toBeObservable(expected$); + }); + + it('should map failed calls to actions of type AddProductToWishlistFail', () => { + const error = { message: 'invalid' } as HttpError; + when(wishlistServiceMock.addProductToWishlist(anyString(), anyString(), anything())).thenReturn( + throwError(error) + ); + const action = new AddProductToWishlist(payload); + const completion = new AddProductToWishlistFail({ + error, + }); + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.addProductToWishlist$).toBeObservable(expected$); + }); + }); + + describe('addProductToNewWishlist$', () => { + const payload = { + title: 'new wishlist', + sku: 'sku', + }; + const wishlist = { + title: 'testing wishlist', + id: '.SKsEQAE4FIAAAFuNiUBWx0d', + itemCount: 0, + preferred: true, + public: false, + }; + beforeEach(() => { + store$.dispatch(new LoginUserSuccess({ customer })); + when(wishlistServiceMock.createWishlist(anything())).thenReturn(of(wishlist)); + }); + it('should map to actions of types CreateWishlistSuccess and AddProductToWishlist', () => { + const action = new AddProductToNewWishlist(payload); + const completion1 = new CreateWishlistSuccess({ wishlist }); + const completion2 = new AddProductToWishlist({ wishlistId: wishlist.id, sku: payload.sku }); + const completion3 = new SelectWishlist({ id: wishlist.id }); + actions$ = hot('-a-----a-----a', { a: action }); + const expected$ = cold('-(bcd)-(bcd)-(bcd)', { b: completion1, c: completion2, d: completion3 }); + expect(effects.addProductToNewWishlist$).toBeObservable(expected$); + }); + it('should map failed calls to actions of type CreateWishlistFail', () => { + const error = { message: 'invalid' } as HttpError; + when(wishlistServiceMock.createWishlist(anything())).thenReturn(throwError(error)); + const action = new AddProductToNewWishlist(payload); + const completion = new CreateWishlistFail({ + error, + }); + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.addProductToNewWishlist$).toBeObservable(expected$); + }); + }); + + describe('moveProductToWishlist$', () => { + const payload1 = { + source: { id: '1234' }, + target: { title: 'new wishlist', sku: 'sku' }, + }; + const payload2 = { + source: { id: '1234' }, + target: { id: '.SKsEQAE4FIAAAFuNiUBWx0d', sku: 'sku' }, + }; + const wishlist = { + title: 'testing wishlist', + id: '.SKsEQAE4FIAAAFuNiUBWx0d', + itemCount: 0, + preferred: true, + public: false, + }; + beforeEach(() => { + store$.dispatch(new LoginUserSuccess({ customer })); + when(wishlistServiceMock.createWishlist(anything())).thenReturn(of(wishlist)); + }); + it('should map to actions of types AddProductToNewWishlist and RemoveItemFromWishlist if there is no target id given', () => { + const action = new MoveItemToWishlist(payload1); + const completion1 = new AddProductToNewWishlist({ title: payload1.target.title, sku: payload1.target.sku }); + const completion2 = new RemoveItemFromWishlist({ wishlistId: payload1.source.id, sku: payload1.target.sku }); + actions$ = hot('-a----a----a', { a: action }); + const expected$ = cold('-(bc)-(bc)-(bc)', { b: completion1, c: completion2 }); + expect(effects.moveItemToWishlist$).toBeObservable(expected$); + }); + it('should map to actions of types AddProductToWishlist and RemoveItemFromWishlist if there is a target id given', () => { + const action = new MoveItemToWishlist(payload2); + const completion1 = new AddProductToWishlist({ wishlistId: wishlist.id, sku: payload1.target.sku }); + const completion2 = new RemoveItemFromWishlist({ wishlistId: payload1.source.id, sku: payload1.target.sku }); + actions$ = hot('-a----a----a', { a: action }); + const expected$ = cold('-(bc)-(bc)-(bc)', { b: completion1, c: completion2 }); + expect(effects.moveItemToWishlist$).toBeObservable(expected$); + }); + }); + + describe('removeProductFromWishlist$', () => { + const payload = { + wishlistId: '.SKsEQAE4FIAAAFuNiUBWx0d', + sku: 'sku', + }; + const wishlist = { + title: 'testing wishlist', + id: '.SKsEQAE4FIAAAFuNiUBWx0d', + itemCount: 0, + preferred: true, + public: false, + }; + beforeEach(() => { + store$.dispatch(new LoginUserSuccess({ customer })); + when(wishlistServiceMock.removeProductFromWishlist(anyString(), anyString())).thenReturn(of(wishlist)); + }); + + it('should call the wishlistService for removeProductFromWishlist', done => { + const action = new RemoveItemFromWishlist(payload); + actions$ = of(action); + + effects.removeProductFromWishlist$.subscribe(() => { + verify(wishlistServiceMock.removeProductFromWishlist(payload.wishlistId, payload.sku)).once(); + done(); + }); + }); + it('should map to actions of type RemoveItemFromWishlistSuccess', () => { + const action = new RemoveItemFromWishlist(payload); + const completion = new RemoveItemFromWishlistSuccess({ wishlist }); + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + expect(effects.removeProductFromWishlist$).toBeObservable(expected$); + }); + it('should map failed calls to actions of type RemoveItemFromWishlistFail', () => { + const error = { message: 'invalid' } as HttpError; + when(wishlistServiceMock.removeProductFromWishlist(anyString(), anyString())).thenReturn(throwError(error)); + const action = new RemoveItemFromWishlist(payload); + const completion = new RemoveItemFromWishlistFail({ + error, + }); + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.removeProductFromWishlist$).toBeObservable(expected$); + }); + }); + + describe('routeListenerForSelectedWishlist$', () => { + it('should map to action of type SelectWishlist', () => { + const wishlistName = '.SKsEQAE4FIAAAFuNiUBWx0d'; + const action = new RouteNavigation({ + path: 'account/wishlist/:wishlistName', + params: { wishlistName }, + queryParams: {}, + }); + const completion = new SelectWishlist({ id: wishlistName }); + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + expect(effects.routeListenerForSelectedWishlist$).toBeObservable(expected$); + }); + }); + + describe('loadWishlistsAfterLogin$', () => { + beforeEach(() => { + when(wishlistServiceMock.getWishlists()).thenReturn(of(wishlists)); + }); + it('should call WishlistsService after login action was dispatched', done => { + effects.loadWishlistsAfterLogin$.subscribe(action => { + expect(action.type).toEqual(WishlistsActionTypes.LoadWishlists); + done(); + }); + + store$.dispatch(new LoginUserSuccess({ customer })); + }); + }); + + describe('resetWishlistStateAfterLogout$', () => { + it('should map to action of type ResetWishlistState if LogoutUser action triggered', () => { + const action = new LogoutUser(); + const completion = new ResetWishlistState(); + actions$ = hot('-a-a-a', { a: action }); + const expected$ = cold('-c-c-c', { c: completion }); + + expect(effects.resetWishlistStateAfterLogout$).toBeObservable(expected$); + }); + }); + + describe('setWishlistBreadcrumb$', () => { + beforeEach(() => { + store$.dispatch(new LoadWishlistsSuccess({ wishlists })); + store$.dispatch(new SelectWishlist({ id: wishlists[0].id })); + }); + + it('should set the breadcrumb of the selected wishlist', done => { + actions$ = of(new RouteNavigation({ path: 'any', params: { wishlistName: wishlists[0].id } })); + effects.setWishlistBreadcrumb$.subscribe(action => { + expect(action.payload).toMatchInlineSnapshot(` + Object { + "breadcrumbData": Array [ + Object { + "key": "account.wishlists.breadcrumb_link", + "link": "/account/wishlists", + }, + Object { + "text": "testing wishlist", + }, + ], + } + `); + done(); + }); + }); + }); +}); diff --git a/src/app/extensions/wishlists/store/wishlist/wishlist.effects.ts b/src/app/extensions/wishlists/store/wishlist/wishlist.effects.ts new file mode 100644 index 0000000000..0eb8195080 --- /dev/null +++ b/src/app/extensions/wishlists/store/wishlist/wishlist.effects.ts @@ -0,0 +1,202 @@ +import { Injectable } from '@angular/core'; +import { Actions, Effect, ofType } from '@ngrx/effects'; +import { Store, select } from '@ngrx/store'; +import { mapToParam, ofRoute } from 'ngrx-router'; +import { filter, map, mapTo, mergeMap, switchMap, switchMapTo, withLatestFrom } from 'rxjs/operators'; + +import { SuccessMessage } from 'ish-core/store/messages'; +import { UserActionTypes, getUserAuthorized } from 'ish-core/store/user'; +import { SetBreadcrumbData } from 'ish-core/store/viewconf'; +import { mapErrorToAction, mapToPayload, mapToPayloadProperty, whenTruthy } from 'ish-core/utils/operators'; + +import { Wishlist, WishlistHeader } from '../../models/wishlist/wishlist.model'; +import { WishlistService } from '../../services/wishlist/wishlist.service'; + +import * as wishlistsActions from './wishlist.actions'; +import { getSelectedWishlistDetails, getSelectedWishlistId } from './wishlist.selectors'; + +@Injectable() +export class WishlistEffects { + constructor(private actions$: Actions, private wishlistService: WishlistService, private store: Store<{}>) {} + + @Effect() + loadWishlists$ = this.actions$.pipe( + ofType(wishlistsActions.WishlistsActionTypes.LoadWishlists), + withLatestFrom(this.store.pipe(select(getUserAuthorized))), + filter(([, authorized]) => authorized), + switchMap(() => + this.wishlistService.getWishlists().pipe( + map(wishlists => new wishlistsActions.LoadWishlistsSuccess({ wishlists })), + mapErrorToAction(wishlistsActions.LoadWishlistsFail) + ) + ) + ); + + @Effect() + createWishlist$ = this.actions$.pipe( + ofType(wishlistsActions.WishlistsActionTypes.CreateWishlist), + mapToPayloadProperty('wishlist'), + mergeMap((wishlistData: WishlistHeader) => + this.wishlistService.createWishlist(wishlistData).pipe( + mergeMap(wishlist => [ + new wishlistsActions.CreateWishlistSuccess({ wishlist }), + new SuccessMessage({ + message: 'account.wishlists.new_wishlist.confirmation', + messageParams: { 0: wishlist.title }, + }), + ]), + mapErrorToAction(wishlistsActions.CreateWishlistFail) + ) + ) + ); + + @Effect() + deleteWishlist$ = this.actions$.pipe( + ofType(wishlistsActions.WishlistsActionTypes.DeleteWishlist), + mapToPayloadProperty('wishlistId'), + mergeMap((wishlistId: string) => + this.wishlistService.deleteWishlist(wishlistId).pipe( + map(() => new wishlistsActions.DeleteWishlistSuccess({ wishlistId })), + mapErrorToAction(wishlistsActions.DeleteWishlistFail) + ) + ) + ); + + @Effect() + updateWishlist$ = this.actions$.pipe( + ofType(wishlistsActions.WishlistsActionTypes.UpdateWishlist), + mapToPayloadProperty('wishlist'), + mergeMap((newWishlist: Wishlist) => + this.wishlistService.updateWishlist(newWishlist).pipe( + map(wishlist => new wishlistsActions.UpdateWishlistSuccess({ wishlist })), + mapErrorToAction(wishlistsActions.UpdateWishlistFail) + ) + ) + ); + + /** + * Reload Wishlists after a creation or update to ensure integrity with server concerning the preferred wishlist + */ + @Effect() + reloadWishlists$ = this.actions$.pipe( + ofType( + wishlistsActions.WishlistsActionTypes.UpdateWishlistSuccess, + wishlistsActions.WishlistsActionTypes.CreateWishlistSuccess + ), + mapToPayloadProperty('wishlist'), + filter(wishlist => wishlist && wishlist.preferred), + mapTo(new wishlistsActions.LoadWishlists()) + ); + + @Effect() + addProductToWishlist$ = this.actions$.pipe( + ofType(wishlistsActions.WishlistsActionTypes.AddProductToWishlist), + mapToPayload(), + mergeMap(payload => + this.wishlistService.addProductToWishlist(payload.wishlistId, payload.sku, payload.quantity).pipe( + map(wishlist => new wishlistsActions.AddProductToWishlistSuccess({ wishlist })), + mapErrorToAction(wishlistsActions.AddProductToWishlistFail) + ) + ) + ); + + @Effect() + addProductToNewWishlist$ = this.actions$.pipe( + ofType(wishlistsActions.WishlistsActionTypes.AddProductToNewWishlist), + mapToPayload(), + mergeMap(payload => + this.wishlistService + .createWishlist({ + title: payload.title, + preferred: false, + }) + .pipe( + // use created wishlist data to dispatch addProduct action + mergeMap(wishlist => [ + new wishlistsActions.CreateWishlistSuccess({ wishlist }), + new wishlistsActions.AddProductToWishlist({ wishlistId: wishlist.id, sku: payload.sku }), + new wishlistsActions.SelectWishlist({ id: wishlist.id }), + ]), + mapErrorToAction(wishlistsActions.CreateWishlistFail) + ) + ) + ); + + @Effect() + moveItemToWishlist$ = this.actions$.pipe( + ofType(wishlistsActions.WishlistsActionTypes.MoveItemToWishlist), + mapToPayload(), + mergeMap(payload => { + if (!payload.target.id) { + return [ + new wishlistsActions.AddProductToNewWishlist({ title: payload.target.title, sku: payload.target.sku }), + new wishlistsActions.RemoveItemFromWishlist({ wishlistId: payload.source.id, sku: payload.target.sku }), + ]; + } else { + return [ + new wishlistsActions.AddProductToWishlist({ wishlistId: payload.target.id, sku: payload.target.sku }), + new wishlistsActions.RemoveItemFromWishlist({ wishlistId: payload.source.id, sku: payload.target.sku }), + ]; + } + }) + ); + + @Effect() + removeProductFromWishlist$ = this.actions$.pipe( + ofType(wishlistsActions.WishlistsActionTypes.RemoveItemFromWishlist), + mapToPayload(), + mergeMap(payload => + this.wishlistService.removeProductFromWishlist(payload.wishlistId, payload.sku).pipe( + map(wishlist => new wishlistsActions.RemoveItemFromWishlistSuccess({ wishlist })), + mapErrorToAction(wishlistsActions.RemoveItemFromWishlistFail) + ) + ) + ); + + @Effect() + routeListenerForSelectedWishlist$ = this.actions$.pipe( + ofRoute(), + mapToParam('wishlistName'), + withLatestFrom(this.store.pipe(select(getSelectedWishlistId))), + filter(([routerId, storeId]) => routerId !== storeId), + map(([id]) => new wishlistsActions.SelectWishlist({ id })) + ); + + /** + * Trigger LoadWishlists action after LoginUserSuccess. + */ + @Effect() + loadWishlistsAfterLogin$ = this.store.pipe( + select(getUserAuthorized), + whenTruthy(), + mapTo(new wishlistsActions.LoadWishlists()) + ); + + /** + * Trigger ResetWishlistState action after LogoutUser. + */ + @Effect() + resetWishlistStateAfterLogout$ = this.actions$.pipe( + ofType(UserActionTypes.LogoutUser), + + mapTo(new wishlistsActions.ResetWishlistState()) + ); + + @Effect() + setWishlistBreadcrumb$ = this.actions$.pipe( + ofRoute(), + mapToParam('wishlistName'), + whenTruthy(), + switchMapTo(this.store.pipe(select(getSelectedWishlistDetails))), + whenTruthy(), + map( + wishlist => + new SetBreadcrumbData({ + breadcrumbData: [ + { key: 'account.wishlists.breadcrumb_link', link: '/account/wishlists' }, + { text: wishlist.title }, + ], + }) + ) + ); +} diff --git a/src/app/extensions/wishlists/store/wishlist/wishlist.reducer.ts b/src/app/extensions/wishlists/store/wishlist/wishlist.reducer.ts new file mode 100644 index 0000000000..eed483620f --- /dev/null +++ b/src/app/extensions/wishlists/store/wishlist/wishlist.reducer.ts @@ -0,0 +1,91 @@ +import { EntityState, createEntityAdapter } from '@ngrx/entity'; + +import { HttpError } from 'ish-core/models/http-error/http-error.model'; + +import { Wishlist } from '../../models/wishlist/wishlist.model'; + +import { WishlistsAction, WishlistsActionTypes } from './wishlist.actions'; + +export interface WishlistState extends EntityState { + loading: boolean; + selected: string; + error: HttpError; +} + +export const wishlistsAdapter = createEntityAdapter({ + selectId: wishlist => wishlist.id, +}); + +export const initialState = wishlistsAdapter.getInitialState({ + loading: false, + selected: undefined, + error: undefined, +}); + +export function wishlistReducer(state = initialState, action: WishlistsAction): WishlistState { + switch (action.type) { + case WishlistsActionTypes.LoadWishlists: + case WishlistsActionTypes.CreateWishlist: + case WishlistsActionTypes.DeleteWishlist: + case WishlistsActionTypes.UpdateWishlist: { + return { + ...state, + loading: true, + }; + } + case WishlistsActionTypes.LoadWishlistsFail: + case WishlistsActionTypes.DeleteWishlistFail: + case WishlistsActionTypes.CreateWishlistFail: + case WishlistsActionTypes.UpdateWishlistFail: { + const { error } = action.payload; + return { + ...state, + loading: false, + error, + selected: undefined, + }; + } + + case WishlistsActionTypes.LoadWishlistsSuccess: { + const { wishlists } = action.payload; + return wishlistsAdapter.addAll(wishlists, { + ...state, + loading: false, + }); + } + + case WishlistsActionTypes.UpdateWishlistSuccess: + case WishlistsActionTypes.AddProductToWishlistSuccess: + case WishlistsActionTypes.RemoveItemFromWishlistSuccess: + case WishlistsActionTypes.CreateWishlistSuccess: { + const { wishlist } = action.payload; + + return wishlistsAdapter.upsertOne(wishlist, { + ...state, + loading: false, + }); + } + + case WishlistsActionTypes.DeleteWishlistSuccess: { + const { wishlistId } = action.payload; + return wishlistsAdapter.removeOne(wishlistId, { + ...state, + loading: false, + }); + } + + case WishlistsActionTypes.SelectWishlist: { + const { id } = action.payload; + return { + ...state, + selected: id, + }; + } + + case WishlistsActionTypes.ResetWishlistState: { + return initialState; + } + } + + return state; +} diff --git a/src/app/extensions/wishlists/store/wishlist/wishlist.selectors.spec.ts b/src/app/extensions/wishlists/store/wishlist/wishlist.selectors.spec.ts new file mode 100644 index 0000000000..46092fe9e0 --- /dev/null +++ b/src/app/extensions/wishlists/store/wishlist/wishlist.selectors.spec.ts @@ -0,0 +1,323 @@ +import { TestBed } from '@angular/core/testing'; +import { combineReducers } from '@ngrx/store'; + +import { HttpError } from 'ish-core/models/http-error/http-error.model'; +import { coreReducers } from 'ish-core/store/core-store.module'; +import { TestStore, ngrxTesting } from 'ish-core/utils/dev/ngrx-testing'; + +import { wishlistsReducers } from '../wishlists-store.module'; + +import { + CreateWishlist, + CreateWishlistFail, + CreateWishlistSuccess, + DeleteWishlist, + DeleteWishlistFail, + DeleteWishlistSuccess, + LoadWishlists, + LoadWishlistsFail, + LoadWishlistsSuccess, + SelectWishlist, + UpdateWishlist, + UpdateWishlistFail, + UpdateWishlistSuccess, +} from './wishlist.actions'; +import { + getAllWishlists, + getPreferredWishlist, + getSelectedWishlistDetails, + getSelectedWishlistId, + getWishlistsError, + getWishlistsLoading, +} from './wishlist.selectors'; + +describe('Wishlist Selectors', () => { + let store$: TestStore; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: ngrxTesting({ + reducers: { + ...coreReducers, + wishlists: combineReducers(wishlistsReducers), + }, + }), + }); + + store$ = TestBed.get(TestStore); + }); + + const wishlists = [ + { + title: 'testing wishlist', + type: 'WishList', + id: '.SKsEQAE4FIAAAFuNiUBWx0d', + itemsCount: 0, + preferred: true, + public: false, + }, + { + title: 'testing wishlist 2', + type: 'WishList', + id: '.AsdHS18FIAAAFuNiUBWx0d', + itemsCount: 0, + preferred: false, + public: false, + }, + ]; + + describe('initial state', () => { + it('should not be loading when in initial state', () => { + expect(getWishlistsLoading(store$.state)).toBeFalse(); + }); + it('should not have a selected wishlist when in initial state', () => { + expect(getSelectedWishlistId(store$.state)).toBeUndefined(); + }); + it('should not have an error when in initial state', () => { + expect(getWishlistsError(store$.state)).toBeUndefined(); + }); + }); + + describe('loading wishlists', () => { + describe('LoadWishlists', () => { + const loadWishlistAction = new LoadWishlists(); + + beforeEach(() => { + store$.dispatch(loadWishlistAction); + }); + + it('should set loading to true', () => { + expect(getWishlistsLoading(store$.state)).toBeTrue(); + }); + }); + + describe('LoadWishlistsSuccess', () => { + const loadWishlistSuccessAction = new LoadWishlistsSuccess({ wishlists }); + + beforeEach(() => { + store$ = TestBed.get(TestStore); + store$.dispatch(loadWishlistSuccessAction); + }); + + it('should set loading to false', () => { + expect(getWishlistsLoading(store$.state)).toBeFalse(); + }); + + it('should add wishlists to state', () => { + expect(getAllWishlists(store$.state)).toEqual(wishlists); + }); + }); + + describe('LoadWishlistsFail', () => { + const loadWishlistFailAction = new LoadWishlistsFail({ error: { message: 'invalid' } as HttpError }); + + beforeEach(() => { + store$ = TestBed.get(TestStore); + store$.dispatch(loadWishlistFailAction); + }); + + it('should set loading to false', () => { + expect(getWishlistsLoading(store$.state)).toBeFalse(); + }); + + it('should add the error to state', () => { + expect(getWishlistsError(store$.state)).toEqual({ message: 'invalid' }); + }); + }); + }); + + describe('create a wishlist', () => { + describe('CreateWishlist', () => { + const createWishlistAction = new CreateWishlist({ + wishlist: { + title: 'create title', + preferred: true, + }, + }); + + beforeEach(() => { + store$.dispatch(createWishlistAction); + }); + + it('should set loading to true', () => { + expect(getWishlistsLoading(store$.state)).toBeTrue(); + }); + }); + + describe('CreateWishlistSuccess', () => { + const createWishistSuccessAction = new CreateWishlistSuccess({ wishlist: wishlists[0] }); + + beforeEach(() => { + store$ = TestBed.get(TestStore); + store$.dispatch(createWishistSuccessAction); + }); + + it('should set loading to false', () => { + expect(getWishlistsLoading(store$.state)).toBeFalse(); + }); + + it('should add new wishlist to state', () => { + expect(getAllWishlists(store$.state)).toContainEqual(wishlists[0]); + }); + }); + + describe('CreateWishlistFail', () => { + const createWishlistFailAction = new CreateWishlistFail({ error: { message: 'invalid' } as HttpError }); + + beforeEach(() => { + store$ = TestBed.get(TestStore); + store$.dispatch(createWishlistFailAction); + }); + + it('should set loading to false', () => { + expect(getWishlistsLoading(store$.state)).toBeFalse(); + }); + + it('should add the error to state', () => { + expect(getWishlistsError(store$.state)).toEqual({ message: 'invalid' }); + }); + }); + }); + + describe('delete a wishlist', () => { + describe('DeleteWishlist', () => { + const deleteWishlistAction = new DeleteWishlist({ wishlistId: 'id' }); + + beforeEach(() => { + store$.dispatch(deleteWishlistAction); + }); + + it('should set loading to true', () => { + expect(getWishlistsLoading(store$.state)).toBeTrue(); + }); + }); + + describe('DeleteWishlistSuccess', () => { + const loadWishlistSuccessAction = new LoadWishlistsSuccess({ wishlists }); + const deleteWishlistSuccessAction = new DeleteWishlistSuccess({ wishlistId: wishlists[0].id }); + + beforeEach(() => { + store$ = TestBed.get(TestStore); + }); + + it('should set loading to false', () => { + store$.dispatch(deleteWishlistSuccessAction); + + expect(getWishlistsLoading(store$.state)).toBeFalse(); + }); + + it('should remove wishlist from state, when wishlist delete action was called', () => { + store$.dispatch(loadWishlistSuccessAction); + store$.dispatch(deleteWishlistSuccessAction); + + expect(getAllWishlists(store$.state)).not.toContain(wishlists[0]); + }); + }); + + describe('DeleteWishlistFail', () => { + const deleteWishlistFailAction = new DeleteWishlistFail({ error: { message: 'invalid' } as HttpError }); + + beforeEach(() => { + store$ = TestBed.get(TestStore); + store$.dispatch(deleteWishlistFailAction); + }); + + it('should set loading to false', () => { + expect(getWishlistsLoading(store$.state)).toBeFalse(); + }); + + it('should add the error to state', () => { + expect(getWishlistsError(store$.state)).toEqual({ message: 'invalid' }); + }); + }); + }); + + describe('updating a wishlist', () => { + describe('UpdateWishlist', () => { + const updateWishlistAction = new UpdateWishlist({ wishlist: wishlists[0] }); + + beforeEach(() => { + store$.dispatch(updateWishlistAction); + }); + + it('should set loading to true', () => { + expect(getWishlistsLoading(store$.state)).toBeTrue(); + }); + }); + + describe('UpdateWishlistSuccess', () => { + const updated = { + ...wishlists[0], + title: 'new title', + }; + const updateWishlistSuccessAction = new UpdateWishlistSuccess({ + wishlist: updated, + }); + const loadWishlistSuccess = new LoadWishlistsSuccess({ wishlists }); + + beforeEach(() => { + store$ = TestBed.get(TestStore); + }); + + it('should set loading to false', () => { + store$.dispatch(updateWishlistSuccessAction); + + expect(getWishlistsLoading(store$.state)).toBeFalse(); + }); + + it('should update wishlist title to new title', () => { + store$.dispatch(loadWishlistSuccess); + store$.dispatch(updateWishlistSuccessAction); + + expect(getAllWishlists(store$.state)).toContainEqual(updated); + }); + }); + + describe('UpdateWishlistFail', () => { + const updateWishlistFailAction = new UpdateWishlistFail({ error: { message: 'invalid' } as HttpError }); + + beforeEach(() => { + store$ = TestBed.get(TestStore); + store$.dispatch(updateWishlistFailAction); + }); + + it('should set loading to false', () => { + expect(getWishlistsLoading(store$.state)).toBeFalse(); + }); + + it('should add the error to state', () => { + expect(getWishlistsError(store$.state)).toEqual({ message: 'invalid' }); + }); + }); + }); + + describe('Get Selected Wishlist', () => { + const loadWishlistsSuccessActions = new LoadWishlistsSuccess({ wishlists }); + const selectWishlistAction = new SelectWishlist({ id: wishlists[1].id }); + + beforeEach(() => { + store$.dispatch(loadWishlistsSuccessActions); + store$.dispatch(selectWishlistAction); + }); + + it('should return correct wishlist id for given id', () => { + expect(getSelectedWishlistId(store$.state)).toEqual(wishlists[1].id); + }); + + it('should return correct wishlist details for given id', () => { + expect(getSelectedWishlistDetails(store$.state)).toEqual(wishlists[1]); + }); + }); + + describe('Get Preferred Wishlist', () => { + const loadWishlistsSuccessActions = new LoadWishlistsSuccess({ wishlists }); + + beforeEach(() => { + store$.dispatch(loadWishlistsSuccessActions); + }); + + it('should return correct wishlist for given title', () => { + expect(getPreferredWishlist(store$.state)).toEqual(wishlists[0]); + }); + }); +}); diff --git a/src/app/extensions/wishlists/store/wishlist/wishlist.selectors.ts b/src/app/extensions/wishlists/store/wishlist/wishlist.selectors.ts new file mode 100644 index 0000000000..140f0ea6b1 --- /dev/null +++ b/src/app/extensions/wishlists/store/wishlist/wishlist.selectors.ts @@ -0,0 +1,40 @@ +import { createSelector } from '@ngrx/store'; + +import { Wishlist } from '../../models/wishlist/wishlist.model'; +import { getWishlistsState } from '../wishlists-store'; + +import { initialState, wishlistsAdapter } from './wishlist.reducer'; + +const getWishlistState = createSelector( + getWishlistsState, + state => (state ? state.wishlists : initialState) +); + +export const { selectEntities: getWishlistEntities, selectAll: getAllWishlists } = wishlistsAdapter.getSelectors( + getWishlistState +); + +export const getWishlistsLoading = createSelector( + getWishlistState, + state => state.loading +); + +export const getWishlistsError = createSelector( + getWishlistState, + state => state.error +); +export const getSelectedWishlistId = createSelector( + getWishlistState, + state => state.selected +); + +export const getSelectedWishlistDetails = createSelector( + getWishlistEntities, + getSelectedWishlistId, + (entities, id): Wishlist => id && entities[id] +); + +export const getPreferredWishlist = createSelector( + getAllWishlists, + entities => entities.find(e => e.preferred) +); diff --git a/src/app/extensions/wishlists/store/wishlists-store.module.ts b/src/app/extensions/wishlists/store/wishlists-store.module.ts new file mode 100644 index 0000000000..8f52e3f3e8 --- /dev/null +++ b/src/app/extensions/wishlists/store/wishlists-store.module.ts @@ -0,0 +1,29 @@ +import { NgModule } from '@angular/core'; +import { EffectsModule } from '@ngrx/effects'; +import { ActionReducerMap, ReducerManager, Store, combineReducers } from '@ngrx/store'; +import { take } from 'rxjs/operators'; + +import { WishlistEffects } from './wishlist/wishlist.effects'; +import { wishlistReducer } from './wishlist/wishlist.reducer'; +import { WishlistsState } from './wishlists-store'; + +export const wishlistsReducers: ActionReducerMap = { + wishlists: wishlistReducer, +}; + +export const wishlistsEffects = [WishlistEffects]; + +const wishlistsFeature = 'wishlists'; + +@NgModule({ + imports: [EffectsModule.forFeature(wishlistsEffects)], +}) +export class WishlistsStoreModule { + constructor(manager: ReducerManager, store: Store<{}>) { + store.pipe(take(1)).subscribe(x => { + if (!x[wishlistsFeature]) { + manager.addReducers({ [wishlistsFeature]: combineReducers(wishlistsReducers) }); + } + }); + } +} diff --git a/src/app/extensions/wishlists/store/wishlists-store.ts b/src/app/extensions/wishlists/store/wishlists-store.ts new file mode 100644 index 0000000000..e167c46ebd --- /dev/null +++ b/src/app/extensions/wishlists/store/wishlists-store.ts @@ -0,0 +1,9 @@ +import { WishlistState } from './wishlist/wishlist.reducer'; + +export interface WishlistsState { + wishlists: WishlistState; +} + +// TODO: use createFeatureSelector after ivy dynamic loading +// tslint:disable-next-line: no-any +export const getWishlistsState: (state: any) => WishlistsState = state => state.wishlists; diff --git a/src/app/extensions/wishlists/wishlists.module.ts b/src/app/extensions/wishlists/wishlists.module.ts new file mode 100644 index 0000000000..0dd53f1fd1 --- /dev/null +++ b/src/app/extensions/wishlists/wishlists.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from '@angular/core'; + +import { SharedModule } from 'ish-shared/shared.module'; + +import { ProductAddToWishlistComponent } from './shared/product/product-add-to-wishlist/product-add-to-wishlist.component'; +import { SelectWishlistModalComponent } from './shared/wishlists/select-wishlist-modal/select-wishlist-modal.component'; +import { WishlistPreferencesDialogComponent } from './shared/wishlists/wishlist-preferences-dialog/wishlist-preferences-dialog.component'; +import { WishlistsLinkComponent } from './shared/wishlists/wishlists-link/wishlists-link.component'; +import { WishlistsStoreModule } from './store/wishlists-store.module'; + +@NgModule({ + imports: [SharedModule, WishlistsStoreModule], + declarations: [ + ProductAddToWishlistComponent, + SelectWishlistModalComponent, + WishlistPreferencesDialogComponent, + WishlistsLinkComponent, + ], + exports: [SelectWishlistModalComponent, WishlistPreferencesDialogComponent], + entryComponents: [ProductAddToWishlistComponent, WishlistsLinkComponent], +}) +export class WishlistsModule {} diff --git a/src/app/pages/account-overview/account-overview/account-overview.component.html b/src/app/pages/account-overview/account-overview/account-overview.component.html index b2995db835..b8ce1448eb 100644 --- a/src/app/pages/account-overview/account-overview/account-overview.component.html +++ b/src/app/pages/account-overview/account-overview/account-overview.component.html @@ -23,6 +23,12 @@ {{ 'account.order_history.link' | translate }} +
diff --git a/src/app/pages/account-overview/account-overview/account-overview.component.spec.ts b/src/app/pages/account-overview/account-overview/account-overview.component.spec.ts index 4a2678b85b..657c6892dc 100644 --- a/src/app/pages/account-overview/account-overview/account-overview.component.spec.ts +++ b/src/app/pages/account-overview/account-overview/account-overview.component.spec.ts @@ -4,8 +4,11 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { MockComponent, MockDirective } from 'ng-mocks'; import { ServerHtmlDirective } from 'ish-core/directives/server-html.directive'; +import { FeatureToggleModule } from 'ish-core/feature-toggle.module'; import { Customer } from 'ish-core/models/customer/customer.model'; import { User } from 'ish-core/models/user/user.model'; +import { configurationReducer } from 'ish-core/store/configuration/configuration.reducer'; +import { ngrxTesting } from 'ish-core/utils/dev/ngrx-testing'; import { OrderWidgetComponent } from 'ish-shared/components/order/order-widget/order-widget.component'; import { LazyQuoteWidgetComponent } from '../../../extensions/quoting/exports/account/lazy-quote-widget/lazy-quote-widget.component'; @@ -29,7 +32,16 @@ describe('Account Overview Component', () => { MockComponent(OrderWidgetComponent), MockDirective(ServerHtmlDirective), ], - imports: [TranslateModule.forRoot()], + imports: [ + FeatureToggleModule, + TranslateModule.forRoot(), + ngrxTesting({ + reducers: { configuration: configurationReducer }, + config: { + initialState: { configuration: { features: ['wishlist'] } }, + }, + }), + ], }).compileComponents(); })); diff --git a/src/app/pages/account/account-navigation/account-navigation.component.ts b/src/app/pages/account/account-navigation/account-navigation.component.ts index 6a2592638a..9a62b5526f 100644 --- a/src/app/pages/account/account-navigation/account-navigation.component.ts +++ b/src/app/pages/account/account-navigation/account-navigation.component.ts @@ -19,6 +19,12 @@ export class AccountNavigationComponent implements OnInit, OnChanges { navigationItems: { link: string; localizationKey: string; dataTestingId?: string; feature?: string }[] = [ { link: '/account', localizationKey: 'account.my_account.link' }, { link: '/account/orders', localizationKey: 'account.order_history.link' }, + { + link: '/account/wishlists', + localizationKey: 'account.wishlists.link', + feature: 'wishlists', + dataTestingId: 'wishlists-link', + }, { link: '/account/payment', localizationKey: 'account.payment.link' }, { link: '/account/addresses', localizationKey: 'account.saved_addresses.link', dataTestingId: 'addresses-link' }, { link: '/account/profile', localizationKey: 'account.profile.link' }, diff --git a/src/app/pages/account/account-page.module.ts b/src/app/pages/account/account-page.module.ts index 50e78eaff6..82db390848 100644 --- a/src/app/pages/account/account-page.module.ts +++ b/src/app/pages/account/account-page.module.ts @@ -84,6 +84,12 @@ const accountPageRoutes: Routes = [ data: { breadcrumbData: [] }, component: AccountOverviewPageModule.component, }, + { + path: 'wishlists', + data: { breadcrumbData: [{ key: 'account.wishlists.breadcrumb_link' }] }, + loadChildren: () => + import('../../extensions/wishlists/pages/wishlists-routing.module').then(m => m.WishlistsRoutingModule), + }, ], }, ]; diff --git a/src/app/pages/product/product-bundle-parts/product-bundle-parts.component.html b/src/app/pages/product/product-bundle-parts/product-bundle-parts.component.html index 928462bd2c..3d6d813e89 100644 --- a/src/app/pages/product/product-bundle-parts/product-bundle-parts.component.html +++ b/src/app/pages/product/product-bundle-parts/product-bundle-parts.component.html @@ -15,6 +15,7 @@

{{ 'product.productBundle.products.heading' | translate }}

displayInventory: false, displayPromotions: false, displayAddToBasket: false, + displayAddToWishlist: false, displayAddToQuote: false, displayAddToCompare: false }" diff --git a/src/app/pages/product/product-detail-actions/product-detail-actions.component.html b/src/app/pages/product/product-detail-actions/product-detail-actions.component.html index 31b65fb828..eab544a8a4 100644 --- a/src/app/pages/product/product-detail-actions/product-detail-actions.component.html +++ b/src/app/pages/product/product-detail-actions/product-detail-actions.component.html @@ -1,6 +1,14 @@
-
+ diff --git a/src/app/shared/components/basket/line-item-list/line-item-list.component.spec.ts b/src/app/shared/components/basket/line-item-list/line-item-list.component.spec.ts index a5493991a5..b8beee1487 100644 --- a/src/app/shared/components/basket/line-item-list/line-item-list.component.spec.ts +++ b/src/app/shared/components/basket/line-item-list/line-item-list.component.spec.ts @@ -3,7 +3,7 @@ import { FormArray, ReactiveFormsModule } from '@angular/forms'; import { RouterTestingModule } from '@angular/router/testing'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { TranslateModule } from '@ngx-translate/core'; -import { MockComponent, MockPipe } from 'ng-mocks'; +import { MockComponent, MockComponents, MockPipe } from 'ng-mocks'; import { anything, spy, verify } from 'ts-mockito'; import { LineItemView } from 'ish-core/models/line-item/line-item.model'; @@ -17,6 +17,9 @@ import { PromotionDetailsComponent } from 'ish-shared/components/promotion/promo import { InputComponent } from 'ish-shared/forms/components/input/input.component'; import { ProductImageComponent } from 'ish-shell/header/product-image/product-image.component'; +import { LazyProductAddToWishlistComponent } from '../../../../extensions/wishlists/exports/products/lazy-product-add-to-wishlist/lazy-product-add-to-wishlist.component'; +import { ProductAddToWishlistComponent } from '../../../../extensions/wishlists/shared/product/product-add-to-wishlist/product-add-to-wishlist.component'; + import { LineItemListComponent } from './line-item-list.component'; describe('Line Item List Component', () => { @@ -32,8 +35,10 @@ describe('Line Item List Component', () => { MockComponent(FaIconComponent), MockComponent(InputComponent), MockComponent(LineItemDescriptionComponent), + MockComponent(ProductAddToWishlistComponent), MockComponent(ProductImageComponent), MockComponent(PromotionDetailsComponent), + MockComponents(LazyProductAddToWishlistComponent), MockPipe(PricePipe), MockPipe(ProductRoutePipe), ], diff --git a/src/app/shared/components/product/product-add-to-basket/product-add-to-basket.component.ts b/src/app/shared/components/product/product-add-to-basket/product-add-to-basket.component.ts index 5993e0dc52..ef24dd917d 100644 --- a/src/app/shared/components/product/product-add-to-basket/product-add-to-basket.component.ts +++ b/src/app/shared/components/product/product-add-to-basket/product-add-to-basket.component.ts @@ -38,7 +38,7 @@ export class ProductAddToBasketComponent implements OnInit, OnDestroy { /** * when 'icon', the button label is an icon, otherwise it is text */ - @Input() displayType?: string; + @Input() displayType?: 'icon' | 'link' = 'link'; /** * additional css styling */ diff --git a/src/app/shared/components/product/product-item/product-item.component.ts b/src/app/shared/components/product/product-item/product-item.component.ts index c637fa82ff..1d814457fd 100644 --- a/src/app/shared/components/product/product-item/product-item.component.ts +++ b/src/app/shared/components/product/product-item/product-item.component.ts @@ -39,6 +39,7 @@ export const DEFAULT_CONFIGURATION: Readonly displayVariations: true, displayShipment: false, displayAddToBasket: true, + displayAddToWishlist: true, displayAddToCompare: true, displayAddToQuote: true, displayType: 'tile', diff --git a/src/app/shared/components/product/product-row/product-row.component.html b/src/app/shared/components/product/product-row/product-row.component.html index 1f65ae4e79..0524242fcf 100644 --- a/src/app/shared/components/product/product-row/product-row.component.html +++ b/src/app/shared/components/product/product-row/product-row.component.html @@ -41,6 +41,12 @@ class="btn-link" (compareToggle)="toggleCompare()" > +
diff --git a/src/app/shared/components/product/product-row/product-row.component.spec.ts b/src/app/shared/components/product/product-row/product-row.component.spec.ts index ee20515ac0..3671fed533 100644 --- a/src/app/shared/components/product/product-row/product-row.component.spec.ts +++ b/src/app/shared/components/product/product-row/product-row.component.spec.ts @@ -25,6 +25,7 @@ import { ProductVariationSelectComponent } from 'ish-shared/components/product/p import { ProductImageComponent } from 'ish-shell/header/product-image/product-image.component'; import { LazyProductAddToQuoteComponent } from '../../../../extensions/quoting/exports/product/lazy-product-add-to-quote/lazy-product-add-to-quote.component'; +import { LazyProductAddToWishlistComponent } from '../../../../extensions/wishlists/exports/products/lazy-product-add-to-wishlist/lazy-product-add-to-wishlist.component'; import { ProductRowComponent } from './product-row.component'; @@ -44,6 +45,7 @@ describe('Product Row Component', () => { ], declarations: [ MockComponent(LazyProductAddToQuoteComponent), + MockComponent(LazyProductAddToWishlistComponent), MockComponent(ProductAddToBasketComponent), MockComponent(ProductAddToCompareComponent), MockComponent(ProductIdComponent), @@ -81,6 +83,7 @@ describe('Product Row Component', () => { expect(findAllIshElements(element)).toMatchInlineSnapshot(` Array [ "ish-lazy-product-add-to-quote", + "ish-lazy-product-add-to-wishlist", "ish-product-add-to-basket", "ish-product-add-to-compare", "ish-product-id", diff --git a/src/app/shared/components/product/product-row/product-row.component.ts b/src/app/shared/components/product/product-row/product-row.component.ts index b6c6ea2389..d32f652f70 100644 --- a/src/app/shared/components/product/product-row/product-row.component.ts +++ b/src/app/shared/components/product/product-row/product-row.component.ts @@ -27,6 +27,7 @@ export interface ProductRowComponentConfiguration { displayVariations: boolean; displayShipment: boolean; displayAddToBasket: boolean; + displayAddToWishlist: boolean; displayAddToCompare: boolean; displayAddToQuote: boolean; } diff --git a/src/app/shared/components/product/product-tile/product-tile.component.html b/src/app/shared/components/product/product-tile/product-tile.component.html index aae943a8d8..67adfd9315 100644 --- a/src/app/shared/components/product/product-tile/product-tile.component.html +++ b/src/app/shared/components/product/product-tile/product-tile.component.html @@ -46,6 +46,12 @@ class="btn-link" (compareToggle)="toggleCompare()" > + { ], declarations: [ MockComponent(LazyProductAddToQuoteComponent), + MockComponent(LazyProductAddToWishlistComponent), MockComponent(ProductAddToBasketComponent), MockComponent(ProductAddToCompareComponent), + MockComponent(ProductAddToWishlistComponent), MockComponent(ProductImageComponent), MockComponent(ProductLabelComponent), MockComponent(ProductPriceComponent), @@ -71,6 +75,7 @@ describe('Product Tile Component', () => { expect(findAllIshElements(element)).toMatchInlineSnapshot(` Array [ "ish-lazy-product-add-to-quote", + "ish-lazy-product-add-to-wishlist", "ish-product-add-to-basket", "ish-product-add-to-compare", "ish-product-image", diff --git a/src/app/shared/components/product/product-tile/product-tile.component.ts b/src/app/shared/components/product/product-tile/product-tile.component.ts index ba0ba0b086..357131a4da 100644 --- a/src/app/shared/components/product/product-tile/product-tile.component.ts +++ b/src/app/shared/components/product/product-tile/product-tile.component.ts @@ -17,6 +17,7 @@ export interface ProductTileComponentConfiguration { displayPrice: boolean; displayPromotions: boolean; displayAddToBasket: boolean; + displayAddToWishlist: boolean; displayAddToCompare: boolean; displayAddToQuote: boolean; } diff --git a/src/app/shared/forms/components/input/input.component.html b/src/app/shared/forms/components/input/input.component.html index e9d9625422..c29f37a0e5 100644 --- a/src/app/shared/forms/components/input/input.component.html +++ b/src/app/shared/forms/components/input/input.component.html @@ -14,6 +14,7 @@ [min]="min || (min === 0 ? 0 : '')" [max]="max || ''" [placeholder]="placeholder | translate" + [attr.disabled]="disAbled" /> diff --git a/src/app/shared/forms/components/input/input.component.ts b/src/app/shared/forms/components/input/input.component.ts index 0191718e40..5c960f8d66 100644 --- a/src/app/shared/forms/components/input/input.component.ts +++ b/src/app/shared/forms/components/input/input.component.ts @@ -18,6 +18,7 @@ export class InputComponent extends FormElementComponent implements OnInit { @Input() max?: number; @Input() placeholder = ''; + @Input() disabled: boolean; calculatedAutocomplete: string; @@ -40,6 +41,10 @@ export class InputComponent extends FormElementComponent implements OnInit { } } + get disAbled(): boolean { + return !this.disabled ? undefined : true; + } + /* set default values for empty input parameters */ diff --git a/src/app/shared/shared.module.ts b/src/app/shared/shared.module.ts index e4be90c928..ea68796741 100644 --- a/src/app/shared/shared.module.ts +++ b/src/app/shared/shared.module.ts @@ -21,6 +21,7 @@ import { PipesModule } from 'ish-core/pipes.module'; import { ShellModule } from 'ish-shell/shell.module'; import { QuotingExportsModule } from '../extensions/quoting/exports/quoting-exports.module'; +import { WishlistsExportsModule } from '../extensions/wishlists/exports/wishlists-exports.module'; import { AddressFormsSharedModule } from './address-forms/address-forms.module'; import { CMSModule } from './cms/cms.module'; @@ -126,6 +127,7 @@ const importExportModules = [ ShellModule, SwiperModule, TranslateModule, + WishlistsExportsModule, ]; const declaredComponents = [ diff --git a/src/app/shell/header/header-default/header-default.component.html b/src/app/shell/header/header-default/header-default.component.html index 96e918e849..4757ef6ef6 100644 --- a/src/app/shell/header/header-default/header-default.component.html +++ b/src/app/shell/header/header-default/header-default.component.html @@ -20,6 +20,9 @@
  • +
  • + +
  • diff --git a/src/app/shell/header/header-default/header-default.component.spec.ts b/src/app/shell/header/header-default/header-default.component.spec.ts index 02a0449133..45fd4de271 100644 --- a/src/app/shell/header/header-default/header-default.component.spec.ts +++ b/src/app/shell/header/header-default/header-default.component.spec.ts @@ -17,6 +17,8 @@ import { ProductCompareStatusComponent } from 'ish-shell/header/product-compare- import { SearchBoxComponent } from 'ish-shell/header/search-box/search-box.component'; import { UserInformationMobileComponent } from 'ish-shell/header/user-information-mobile/user-information-mobile.component'; +import { LazyWishlistLinkComponent } from '../../../extensions/wishlists/exports/wishlists/lazy-wishlist-link/lazy-wishlist-link.component'; + import { HeaderDefaultComponent } from './header-default.component'; describe('Header Default Component', () => { @@ -42,6 +44,7 @@ describe('Header Default Component', () => { MockComponent(FaIconComponent), MockComponent(HeaderNavigationComponent), MockComponent(LanguageSwitchComponent), + MockComponent(LazyWishlistLinkComponent), MockComponent(LoginStatusComponent), MockComponent(MiniBasketComponent), MockComponent(NgbCollapse), diff --git a/src/app/shell/header/user-information-mobile/user-information-mobile.component.html b/src/app/shell/header/user-information-mobile/user-information-mobile.component.html index 3386e31f22..76827c906e 100644 --- a/src/app/shell/header/user-information-mobile/user-information-mobile.component.html +++ b/src/app/shell/header/user-information-mobile/user-information-mobile.component.html @@ -4,5 +4,8 @@
    +
    + +
    diff --git a/src/app/shell/header/user-information-mobile/user-information-mobile.component.spec.ts b/src/app/shell/header/user-information-mobile/user-information-mobile.component.spec.ts index 9ef066475d..0d70ba62a7 100644 --- a/src/app/shell/header/user-information-mobile/user-information-mobile.component.spec.ts +++ b/src/app/shell/header/user-information-mobile/user-information-mobile.component.spec.ts @@ -10,6 +10,8 @@ import { LoginStatusComponent } from 'ish-shell/header/login-status/login-status import { MiniBasketComponent } from 'ish-shell/header/mini-basket/mini-basket.component'; import { ProductCompareStatusComponent } from 'ish-shell/header/product-compare-status/product-compare-status.component'; +import { LazyWishlistLinkComponent } from '../../../extensions/wishlists/exports/wishlists/lazy-wishlist-link/lazy-wishlist-link.component'; + import { UserInformationMobileComponent } from './user-information-mobile.component'; describe('User Information Mobile Component', () => { @@ -31,6 +33,7 @@ describe('User Information Mobile Component', () => { ], declarations: [ MockComponent(LanguageSwitchComponent), + MockComponent(LazyWishlistLinkComponent), MockComponent(LoginStatusComponent), MockComponent(MiniBasketComponent), MockComponent(ProductCompareStatusComponent), diff --git a/src/app/shell/shell.module.ts b/src/app/shell/shell.module.ts index 7f54a21dc2..c8e83df13d 100644 --- a/src/app/shell/shell.module.ts +++ b/src/app/shell/shell.module.ts @@ -13,6 +13,7 @@ import { IconModule } from 'ish-core/icon.module'; import { PipesModule } from 'ish-core/pipes.module'; import { QuotingExportsModule } from '../extensions/quoting/exports/quoting-exports.module'; +import { WishlistsExportsModule } from '../extensions/wishlists/exports/wishlists-exports.module'; import { FooterComponent } from './footer/footer/footer.component'; import { HeaderCheckoutComponent } from './header/header-checkout/header-checkout.component'; @@ -55,6 +56,7 @@ const exportedComponents = [ }), RouterModule, TranslateModule, + WishlistsExportsModule, ], declarations: [ ...exportedComponents, diff --git a/src/assets/i18n/de_DE.json b/src/assets/i18n/de_DE.json index c19a2824b2..63249fabd7 100644 --- a/src/assets/i18n/de_DE.json +++ b/src/assets/i18n/de_DE.json @@ -868,7 +868,7 @@ "account.wishlist.your_name.label": "Ihr Name", "account.wishlists.add_to_wishlist.add_button.text": "Hinzufügen", "account.wishlists.add_to_wishlist.cancel_button.text": "Abbrechen", - "account.wishlists.add_to_wishlist.confirmation": "{{0}} hinzugefügt zu \"{{1}}\"", + "account.wishlists.add_to_wishlist.confirmation": "{{0}} hinzugefügt zu {{2}}.", "account.wishlists.add_to_wishlist.confirmation.ok_button.text": "OK", "account.wishlists.add_to_wishlist.error": "Das Produkt konnte nicht zu Ihrer Wunschliste hinzugefügt werden.", "account.wishlists.add_to_wishlist.no_product.message": "Es gibt keine Produkte, die in die Wunschliste aufgenommen werden sollen.", @@ -876,7 +876,7 @@ "account.wishlists.add_wishlist": "Wunschliste hinzufügen", "account.wishlists.breadcrumb_link": "Wunschlisten", "account.wishlists.choose_wishlist.new_wishlist_name.initial_value": "Neue Wunschliste", - "account.wishlists.delete_wishlist.confirmation": "Wunschliste \"{{0}}\" wurde gelöscht.", + "account.wishlists.delete_wishlist.confirmation": "Die Wunschliste \"{{0}}\" wurde gelöscht.", "account.wishlists.delete_wishlist.confirmation.ok_button.text": "OK", "account.wishlists.delete_wishlist_dialog.are_you_sure_paragraph": "Diese Wunschliste wirklich löschen? Alle Artikel der Wunschliste werden entfernt.", "account.wishlists.delete_wishlist_dialog.cancel_button.text": "Abbrechen", @@ -889,23 +889,25 @@ "account.wishlists.edit_wishlist_dialog.header": "Wunschliste bearbeiten", "account.wishlists.edit_wishlist_form.save_button.text": "Änderungen speichern", "account.wishlists.heading": "Wunschlisten", - "account.wishlists.heading.tooltip.content": "Jede Liste hat andere Einstellungen:

    Veröffentlichen erlaubt anderen Kunden, die Wunschliste zu suchen und einzusehen. Einzelne Artikel können auf "Nicht freigeben"gesetzt werden. Diese Artikel sind nur für Sie sichtbar, auch wenn die Liste öffentlich oder für einen Freund freigegeben ist.

    Als bevorzugte Liste einstellen markiert eine Wunschliste als bevorzugte Liste und setzt alle anderen (wenn vorhanden) zurück auf Standard. Alle hinzugefügten Produkte werden standardmäßig zu dieser Wunschliste hinzugefügt.

    Stückzahl ermöglicht Ihnen, Mengenangaben für die Artikel auf Ihrer Liste zu machen.

    Per E-Mail können Sie Wunschlisten mit Freunden teilen. Das funktioniert auch mit nicht öffentlichen Listen. Wenn Sie eine Liste nicht mehr teilen wollen, können Sie die Freigabe aufheben, indem Sie den Freigabe-Link der Wunschliste deaktivieren.

    Auf Ihrer Wunschliste können Sie sehen, ob (und wie viele) Artikel schon von Ihnen und Ihren Freunden gekauft wurden. Ihre Freunde können diese Informationen auch in freigegebenen und öffentlichen Listen einsehen. Dadurch werden Doppelkäufe von Artikeln vermieden.", + "account.wishlists.heading.tooltip.content": "Jede Liste hat andere Einstellungen:
    Als bevorzugte Liste einstellen markiert eine Wunschliste als bevorzugte Liste und setzt alle anderen (wenn vorhanden) zurück auf Standard. Alle hinzugefügten Produkte werden standardmäßig zu dieser Wunschliste hinzugefügt.", "account.wishlists.heading.tooltip.headline": "Wunschlisten", "account.wishlists.heading.tooltip.link": "Wie funktionieren Wunschlisten?", "account.wishlists.heading_search": "Wunschlistensuche", - "account.wishlists.items": "Artikel", + "account.wishlists.items": { + "other": "# Artikel" + }, "account.wishlists.link": "Wunschlisten", "account.wishlists.move_from_cart_to_wishlist.confirmation": "{{0}} aus Warenkorb verschoben nach \"{{1}}\"", "account.wishlists.move_from_cart_to_wishlist.confirmation.ok_button.text": "OK", "account.wishlists.move_wishlist_item.cancel_button.text": "Abbrechen", - "account.wishlists.move_wishlist_item.confirmation": "{{0}} verschoben nach {{2}}.", + "account.wishlists.move_wishlist_item.confirmation": "{{0}} verschoben nach {{2}}.", "account.wishlists.move_wishlist_item.confirmation.ok_button.text": "OK", "account.wishlists.move_wishlist_item.move_button.text": "Verschieben", "account.wishlists.move_wishlist_item.quantity.label": "Anzahl", "account.wishlists.move_wishlist_item.quantity.maximum.validation.message": "Es kann nicht mehr als die gewünschte Menge verschoben werden.", "account.wishlists.move_wishlist_item.quantity.minimum.validation.message": "Geben Sie eine Menge > 0 an.", "account.wishlists.move_wishlist_item.quantity.validation.message": "Geben Sie eine gültige Menge an.", - "account.wishlists.new_wishlist.confirmation": "Wunschliste \"{{0}}\" wurde erfolgreich erstellt. ", + "account.wishlists.new_wishlist.confirmation": "Die Wunschliste \"{{0}}\" wurde erstellt. ", "account.wishlists.new_wishlist.confirmation.ok_button.text": "OK", "account.wishlists.new_wishlist_dialog.header": "Wunschliste hinzufügen", "account.wishlists.new_wishlist_form.create_button.text": "Anlegen", @@ -919,7 +921,7 @@ "account.wishlists.settings.header": "Einstellungen Wunschliste", "account.wishlists.settings.save_settings": "Einstellungen speichern", "account.wishlists.settings.use_preferred_list": "Beim Hinzufügen neuer Produkte immer bevorzugte Wunschliste verwenden.", - "account.wishlists.table.preferred": "(bevorzugt)", + "account.wishlists.table.preferred": "StandardListe", "account.wishlists.table.private": "privat", "account.wishlists.table.public": "öffentlich", "account.wishlists.unavailable_wishlist_breadcrumb": "Wunschliste nicht verfügbar", diff --git a/src/assets/i18n/en_US.json b/src/assets/i18n/en_US.json index f2bc4da0d0..7b484ce244 100644 --- a/src/assets/i18n/en_US.json +++ b/src/assets/i18n/en_US.json @@ -544,7 +544,7 @@ "account.productnotification.login.username.label": "Username", "account.productnotification.login.username_password.error.invalid": "Your Username/Password combination is incorrect. Please try again.", "account.productnotification.login.username_password.error.required": "Please enter both a username and password.", - "account.productnotification.remove.confirmation.message": "Your product notification was deleted.", + "account.productnotification.remove.confirmation.message": "Your product notification has been deleted.", "account.productnotification.remove_dialog.cannot_deleted.error": "This product notification could not be deleted.", "account.productnotification.remove_dialog.label_no": "Cancel", "account.productnotification.remove_dialog.label_yes": "Delete", @@ -792,7 +792,7 @@ "account.validate_address.no_result": "Our system could not confirm your address to be valid and cannot find a recommended alternative. It is strongly recommended that you click \u201cEdit Address\u201d to go back to the address form and try again. You may also select the address below as you entered it if you are sure it is correct:", "account.view_order.link": "View Order", "account.wishlist.cancel.button.text": "Cancel", - "account.wishlist.disclaimer.text": "The contact information you give above will not be collected, sold or used for anything other that this e-mail message. See our ", + "account.wishlist.disclaimer.text": "The contact information you give above will not be collected, sold or used for anything other than this e-mail message. See our ", "account.wishlist.disclaimer.text.privacy_policy": "Privacy Policy", "account.wishlist.disclaimer.text.suffix": "for more.", "account.wishlist.disclaimer.text2": "Online prices and product selection may vary from our retail stores. \n All prices and offers are subject to change.", @@ -868,7 +868,7 @@ "account.wishlist.your_name.label": "Your Name", "account.wishlists.add_to_wishlist.add_button.text": "Add", "account.wishlists.add_to_wishlist.cancel_button.text": "Cancel", - "account.wishlists.add_to_wishlist.confirmation": "{{0}} added to \"{{1}}\"", + "account.wishlists.add_to_wishlist.confirmation": "{{0}} added to {{2}}.", "account.wishlists.add_to_wishlist.confirmation.ok_button.text": "OK", "account.wishlists.add_to_wishlist.error": "The product could not be added to your wish list.", "account.wishlists.add_to_wishlist.no_product.message": "There are no products to be added to the wish list.", @@ -876,7 +876,7 @@ "account.wishlists.add_wishlist": "Add Wish List", "account.wishlists.breadcrumb_link": "Wish Lists", "account.wishlists.choose_wishlist.new_wishlist_name.initial_value": "New Wish List", - "account.wishlists.delete_wishlist.confirmation": "Wish list \"{{0}}\" was deleted.", + "account.wishlists.delete_wishlist.confirmation": "The wish list \"{{0}}\" has been deleted.", "account.wishlists.delete_wishlist.confirmation.ok_button.text": "OK", "account.wishlists.delete_wishlist_dialog.are_you_sure_paragraph": "Do you really want to delete this wish list? All products in the wish list will be removed.", "account.wishlists.delete_wishlist_dialog.cancel_button.text": "Cancel", @@ -889,23 +889,27 @@ "account.wishlists.edit_wishlist_dialog.header": "Edit Wish List", "account.wishlists.edit_wishlist_form.save_button.text": "Save Changes", "account.wishlists.heading": "Wish Lists", - "account.wishlists.heading.tooltip.content": "Every list has its own settings:

    Make Public allows other consumers to search and view the wish list. However it is possible to set individual items to "do not share". These items are only visible for you even if the list is public or shared with a friend.

    Set as Preferred makes a wish list your preferred wish list and sets all other lists (if any) back to normal. All added products will be added by default to this wish list.

    Quantity Information allows you to enter quantity information for the items on your list.

    You can share wish lists with friends by sending them a mail. This works with non-public lists, too. If you don't want to share you can stop the sharing by disabling the sharing-link for this wish list.

    On your wish list you can see if (and how many) items have already been purchased by yourself and by your friends. Your friends can see this information on shared and public lists, too. This prevents double purchases of items.", + "account.wishlists.heading.tooltip.content": "Every list has its own settings:

    Set as Preferred makes a wish list your preferred wish list and sets all other lists (if any) back to normal. All added products will be added by default to this wish list.", "account.wishlists.heading.tooltip.headline": "Wish Lists", "account.wishlists.heading.tooltip.link": "How do Wish Lists work?", "account.wishlists.heading_search": "Wish List Search", - "account.wishlists.items": "Items", + "account.wishlists.items": { + "=0": "0 Items", + "=1": "1 Item", + "other": "# Items" + }, "account.wishlists.link": "Wish Lists", "account.wishlists.move_from_cart_to_wishlist.confirmation": "{{0}} moved from the shopping cart to \"{{1}}\"", "account.wishlists.move_from_cart_to_wishlist.confirmation.ok_button.text": "OK", "account.wishlists.move_wishlist_item.cancel_button.text": "Cancel", - "account.wishlists.move_wishlist_item.confirmation": "Moved {{0}} to {{2}}.", + "account.wishlists.move_wishlist_item.confirmation": "Moved {{0}} to {{2}}.", "account.wishlists.move_wishlist_item.confirmation.ok_button.text": "OK", "account.wishlists.move_wishlist_item.move_button.text": "Move", "account.wishlists.move_wishlist_item.quantity.label": "Quantity", "account.wishlists.move_wishlist_item.quantity.maximum.validation.message": "Cannot move more than the desired quantity.", - "account.wishlists.move_wishlist_item.quantity.minimum.validation.message": "Please enter quantity greater than 0", + "account.wishlists.move_wishlist_item.quantity.minimum.validation.message": "Please enter a quantity greater than 0", "account.wishlists.move_wishlist_item.quantity.validation.message": "Please enter a valid quantity", - "account.wishlists.new_wishlist.confirmation": "Wish list \"{{0}}\" was successfully created. ", + "account.wishlists.new_wishlist.confirmation": "The wish list \"{{0}}\" has been created.", "account.wishlists.new_wishlist.confirmation.ok_button.text": "OK", "account.wishlists.new_wishlist_dialog.header": "Add Wish List", "account.wishlists.new_wishlist_form.create_button.text": "Create", @@ -919,7 +923,7 @@ "account.wishlists.settings.header": "Wish List Settings", "account.wishlists.settings.save_settings": "Save Settings", "account.wishlists.settings.use_preferred_list": "Always use preferred wish list when adding products", - "account.wishlists.table.preferred": "(preferred)", + "account.wishlists.table.preferred": "Preferred", "account.wishlists.table.private": "private", "account.wishlists.table.public": "public", "account.wishlists.unavailable_wishlist_breadcrumb": "Wish List Not Available", @@ -2120,7 +2124,7 @@ "review.register_or_login.register.text": "If you're not yet a user, please create an account to rate products.", "review.register_or_login.returning_users.title": "Returning users", "review.register_or_login.title": "Rate and review this item and share your opinion with others.", - "review.remove.confirmation.text": "Your review was deleted.", + "review.remove.confirmation.text": "Your review has been deleted.", "review.remove_review.cancel": "No", "review.remove_review.confirm": "Yes", "review.remove_review.link": "Remove Review", diff --git a/src/assets/i18n/fr_FR.json b/src/assets/i18n/fr_FR.json index 5740cf35bf..b4b312034d 100644 --- a/src/assets/i18n/fr_FR.json +++ b/src/assets/i18n/fr_FR.json @@ -868,7 +868,7 @@ "account.wishlist.your_name.label": "Votre nom", "account.wishlists.add_to_wishlist.add_button.text": "Ajouter", "account.wishlists.add_to_wishlist.cancel_button.text": "Annuler", - "account.wishlists.add_to_wishlist.confirmation": "{{0}} ajoutés à \"{{1}}\"", + "account.wishlists.add_to_wishlist.confirmation": "{{0}} ajoutés à {{2}}.", "account.wishlists.add_to_wishlist.confirmation.ok_button.text": "OK", "account.wishlists.add_to_wishlist.error": "Le produit n’a pas pu être ajouté à votre liste de souhaits.", "account.wishlists.add_to_wishlist.no_product.message": "Il n’y a aucun produit à ajouter sur la liste de souhaits", @@ -889,23 +889,27 @@ "account.wishlists.edit_wishlist_dialog.header": "Modifier une liste de souhaits", "account.wishlists.edit_wishlist_form.save_button.text": "Enregistrer les modifications", "account.wishlists.heading": "Listes de souhaits", - "account.wishlists.heading.tooltip.content": "Chaque liste a ses propres paramètres : 

    Rendre public permet aux autres consommateurs de rechercher et de visualiser la liste de souhaits. Cependant, il est possible de régler des articles individuellement sur « Ne pas partager ». Ces articles sont seulement visibles pour vous même si la liste est publique ou partagée avec un ami.

    Définir comme Préférée fait d’une liste de souhaits votre liste de souhaits préférée et remet toutes les autres listes (s’il y en a) à l’état normal. Tous les produits ajoutés seront ajoutés par défaut à cette liste de souhaits.

    Informations de quantité vous permet d’entrer des informations de quantité pour les articles de votre liste.

    Vous pouvez partager des listes de souhaits avec vos amis en leur envoyant un courriel. Cela fonctionne aussi avec les listes non publiques. Si vous ne voulez pas partager, vous pouvez arrêter le partage en désactivant le lien de partage pour cette liste de souhaits

    Sur votre liste de souhaits, vous pouvez voir si des articles (et combien d’articles) ont déjà été achetés par vous-même et par vos amis. Vos amis peuvent également voir ces informations sur les listes publiques et partagées. Cela permet d’éviter les doubles achats d’articles.", + "account.wishlists.heading.tooltip.content": "Chaque liste a ses propres paramètres : 

    Définir comme Préférée fait d’une liste de souhaits votre liste de souhaits préférée et remet toutes les autres listes (s’il y en a) à l’état normal. Tous les produits ajoutés seront ajoutés par défaut à cette liste de souhaits.", "account.wishlists.heading.tooltip.headline": "Listes de souhaits", "account.wishlists.heading.tooltip.link": "Comment fonctionnent les listes de souhaits ?", "account.wishlists.heading_search": "Recherche de listes de souhaits", - "account.wishlists.items": "Articles", + "account.wishlists.items": { + "=0": "0 Articles", + "=1": "1 Article", + "other": "# Articles" + }, "account.wishlists.link": "Listes de souhaits", "account.wishlists.move_from_cart_to_wishlist.confirmation": "{{0}} était déplacé(e) du panier à \"{{1}}\"", "account.wishlists.move_from_cart_to_wishlist.confirmation.ok_button.text": "OK", "account.wishlists.move_wishlist_item.cancel_button.text": "Annuler", - "account.wishlists.move_wishlist_item.confirmation": "Déplacer {{0}} à {{2}}.", + "account.wishlists.move_wishlist_item.confirmation": "Déplacer {{0}} à {{2}}.", "account.wishlists.move_wishlist_item.confirmation.ok_button.text": "OK", "account.wishlists.move_wishlist_item.move_button.text": "Déplacer", "account.wishlists.move_wishlist_item.quantity.label": "Quantité", "account.wishlists.move_wishlist_item.quantity.maximum.validation.message": "Impossible de déplacer plus que la quantité souhaitée.", "account.wishlists.move_wishlist_item.quantity.minimum.validation.message": "Veuillez entrer une quantité supérieure à 0", "account.wishlists.move_wishlist_item.quantity.validation.message": "Veuillez entrer une quantité valide.", - "account.wishlists.new_wishlist.confirmation": "Création de la liste de souhaits {{0}} réussie. ", + "account.wishlists.new_wishlist.confirmation": "La liste de souhaits {{0}} a été créée.", "account.wishlists.new_wishlist.confirmation.ok_button.text": "OK", "account.wishlists.new_wishlist_dialog.header": "Ajouter une liste de souhaits", "account.wishlists.new_wishlist_form.create_button.text": "Créer", diff --git a/src/environments/environment.model.ts b/src/environments/environment.model.ts index 3b47d9fb9c..4be93ab5df 100644 --- a/src/environments/environment.model.ts +++ b/src/environments/environment.model.ts @@ -33,7 +33,9 @@ export interface Environment { | 'quoting' /* Third-party Integrations */ | 'sentry' - | 'tracking')[]; + | 'tracking' + /* B2C features */ + | 'wishlists')[]; /* ADDITIONAL FEATURE CONFIGURATIONS */ diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index 53b9ab98dd..05c09ffe5e 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -10,7 +10,7 @@ export const environment: Environment = { icmChannel: 'inSPIRED-inTRONICS-Site', /* FEATURE TOOGLES */ - features: ['compare', 'recently', 'rating'], + features: ['compare', 'recently', 'rating', 'wishlists'], /* PROGRESSIVE WEB APP CONFIGURATIONS */ smallBreakpointWidth: 576, diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 54a6ef96e5..21572714f2 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -17,7 +17,7 @@ export const environment: Environment = { mockServerAPI: true, /* FEATURE TOOGLES */ - features: ['compare', 'recently', 'rating'], + features: ['compare', 'recently', 'rating', 'wishlists'], /* PROGRESSIVE WEB APP CONFIGURATIONS */ smallBreakpointWidth: 576, diff --git a/src/styles/components/header/header.scss b/src/styles/components/header/header.scss index f91aaa2600..7190cfb73b 100644 --- a/src/styles/components/header/header.scss +++ b/src/styles/components/header/header.scss @@ -122,7 +122,7 @@ header { li { float: left; - padding-right: ($space-default * 2); + padding-right: ($space-default * 3 / 2); font-family: $font-family-bold; font-size: 12px; color: $text-color-quarternary; @@ -155,7 +155,7 @@ header { .ng-fa-icon.header-icon { position: relative; - padding: 0 $space-default; + padding: 0 10px 0 4px; font-size: 17px; color: $text-color-quarternary; diff --git a/src/styles/global/buttons.scss b/src/styles/global/buttons.scss index 6263e29935..e02134d6a1 100644 --- a/src/styles/global/buttons.scss +++ b/src/styles/global/buttons.scss @@ -97,4 +97,8 @@ margin-bottom: 0; clear: none; } + + .btn-tool { + margin-right: $space-default/2; + } } diff --git a/src/styles/global/global.scss b/src/styles/global/global.scss index 5419711dc6..424a2c5a53 100644 --- a/src/styles/global/global.scss +++ b/src/styles/global/global.scss @@ -166,7 +166,7 @@ img.marketing { .badge { position: absolute; top: -8px; - left: 26px; + left: 16px; font-size: 75%; font-weight: inherit; line-height: 1.4; diff --git a/src/styles/global/tables.scss b/src/styles/global/tables.scss index 6105134405..6d126025c3 100644 --- a/src/styles/global/tables.scss +++ b/src/styles/global/tables.scss @@ -15,7 +15,6 @@ font-size: 18px; font-weight: normal; text-transform: uppercase; - // border-right: 4px solid #fff; // TODO: needed? } // RESPONSIVE TABLE @@ -129,7 +128,7 @@ } .list-item { - padding: $table-cell-padding * 0.7 $table-cell-padding * 0.7 $table-cell-padding * 0.7 0; + padding: $table-cell-padding * 0.9 $table-cell-padding * 0.7 $table-cell-padding * 0.9 0; .list-item { padding: 0; @@ -190,10 +189,10 @@ .column-price, .column-action { text-align: right; - // TODO: are we shure we want that? - // @media (max-width: $screen-xs-max) { - // text-align: left; - // } + + @media (max-width: $screen-xs-max) { + text-align: left; + } } // form element in tables diff --git a/src/styles/pages/category/product-list.scss b/src/styles/pages/category/product-list.scss index 7e14b9290e..47139dbc6a 100644 --- a/src/styles/pages/category/product-list.scss +++ b/src/styles/pages/category/product-list.scss @@ -59,28 +59,6 @@ } } } - - .product-list-actions-container { - .action-container { - margin-top: $space-default/2; - } - - .product-quantity .form-group { - @include media-breakpoint-down(lg) { - margin-bottom: 0; - } - } - - .addtocart-container { - @include media-breakpoint-up(lg) { - padding-left: 0; - } - - .add-to-cart button { - width: 100%; - } - } - } } .product-list-item { @@ -153,6 +131,8 @@ .add-to-cart, .add-to-wishlist, .add-to-compare { + margin-right: 0; + &.is-selected { color: $text-muted; } @@ -336,3 +316,25 @@ } } } + +.product-list-actions-container { + .action-container { + margin-top: $space-default/2; + } + + .product-quantity .form-group { + @include media-breakpoint-down(lg) { + margin-bottom: 0; + } + } + + .addtocart-container { + @include media-breakpoint-up(lg) { + padding-left: 0; + } + + .add-to-cart button { + width: 100%; + } + } +} diff --git a/tslint.json b/tslint.json index ae19b41bc0..acf77d1f58 100644 --- a/tslint.json +++ b/tslint.json @@ -327,7 +327,7 @@ }, { "starImport": true, - "from": "(\\.\\.|ish).*", + "from": "^(\\.\\.|ish).*", "filePattern": ".*src/app.*", "message": "use star imports only for aggregation of deeper lying imports" },