Skip to content

Commit

Permalink
feat: create a product review on product detail page (#1198)
Browse files Browse the repository at this point in the history
  • Loading branch information
SGrueber committed Jul 5, 2022
1 parent 80a69c3 commit 9ac2f85
Show file tree
Hide file tree
Showing 32 changed files with 1,203 additions and 126 deletions.
12 changes: 7 additions & 5 deletions e2e/cypress/integration/pages/shopping/product-detail.page.ts
Expand Up @@ -6,6 +6,7 @@ import { HeaderModule } from '../header.module';
import { MetaDataModule } from '../meta-data.module';

import { ProductListModule } from './product-list.module';
import { ProductReviewModule } from './product-review.module';

export class ProductDetailPage {
readonly tag = 'ish-product-page';
Expand All @@ -21,6 +22,8 @@ export class ProductDetailPage {
readonly addToWishlist = new AddToWishlistModule();
readonly addToOrderTemplate = new AddToOrderTemplateModule();

reviewTab = new ProductReviewModule();

static navigateTo(sku: string, categoryUniqueId?: string) {
if (categoryUniqueId) {
cy.visit(`/sku${sku}-cat${categoryUniqueId}`);
Expand Down Expand Up @@ -84,6 +87,10 @@ export class ProductDetailPage {
this.quantityInput().type(quantity.toString());
}

get infoText() {
return cy.get('.toast-container .toast-info');
}

get recentlyViewedItems() {
return cy.get('[data-testing-id="recently-viewed"] ish-product-tile');
}
Expand All @@ -96,14 +103,9 @@ export class ProductDetailPage {
cy.get('[data-testing-id="recently-viewed"] [data-testing-id=view-all]').click();
}

accordionItem(id: string) {
return cy.get('ish-accordion-item a.accordion-toggle').contains(id);
}

infoNav(id: string) {
return cy.get('ish-product-detail-info li.nav-item').contains(id);
}

changeVariationWithSelect(id: string, value: string) {
cy.get(`[data-testing-id="${id}"]`).select(value);
}
Expand Down
60 changes: 60 additions & 0 deletions e2e/cypress/integration/pages/shopping/product-review.module.ts
@@ -0,0 +1,60 @@
import { fillFormField, waitLoadingEnd } from '../../framework';

declare interface ProductReviewForm {
rating: number;
title: string;
content: string;
}

export class ProductReviewModule {
// product rating&reviews
get productReviewList() {
return cy.get('[data-testing-id=product-review-list]');
}

get reviewLoginLink() {
return cy.get('ish-product-reviews').find('[data-testing-id=login-link]');
}

get reviewOpenDialogLink() {
return cy.get('ish-product-reviews').find('[data-testing-id=open-review-dialog]');
}

get reviewCreationForm() {
return cy.get('#createProductReviewForm');
}

get ownProductReview() {
return cy.get('[data-testing-id=own-product-review');
}

get deleteReviewLink() {
return cy.get('[data-testing-id=delete-review]');
}

gotoLoginBeforeOpenReviewDialog() {
this.reviewLoginLink.click();
}

fillReviewForm(data: ProductReviewForm) {
this.reviewCreationForm.find(`[data-testing-id=rating-stars-field] :nth-child(${data.rating})`).click();

Object.keys(data)
.filter(key => data[key] !== undefined && key !== 'rating')
.forEach((key: keyof ProductReviewForm) => {
fillFormField('#createProductReviewForm', key, data[key]);
});

return this;
}

submitReviewCreationForm() {
cy.get('button[form=createProductReviewForm]').click();
}

deleteOwnReview() {
this.deleteReviewLink.click();
cy.get('[data-testing-id="confirm"]', { timeout: 1000 }).click();
waitLoadingEnd(1000);
}
}
@@ -0,0 +1,70 @@
import { at } from '../../framework';
import { createB2BUserViaREST } from '../../framework/b2b-user';
import { LoginPage } from '../../pages/account/login.page';
import { sensibleDefaults } from '../../pages/account/registration.page';
import { ProductDetailPage } from '../../pages/shopping/product-detail.page';

const _ = {
user: {
login: `test${new Date().getTime()}@testcity.de`,
...sensibleDefaults,
},
product: {
sku: '6997041',
},
};

describe('Product Reviews', () => {
before(() => {
createB2BUserViaREST(_.user);
ProductDetailPage.navigateTo(_.product.sku);
});

it('anonymous user should see a product review on product review tab', () => {
at(ProductDetailPage, page => {
page.infoNav('Reviews').should('be.visible');
page.infoNav('Reviews').click();
page.reviewTab.productReviewList.should('be.visible');
page.reviewTab.deleteReviewLink.should('not.exist');
});
});

it('user should log in before he can write a review', () => {
at(ProductDetailPage, page => {
page.reviewTab.reviewOpenDialogLink.should('not.exist');
page.reviewTab.ownProductReview.should('not.exist');
page.reviewTab.gotoLoginBeforeOpenReviewDialog();
});
at(LoginPage, page => {
page.fillForm(_.user.login, _.user.password);
page.submit().its('response.statusCode').should('equal', 200);
});
at(ProductDetailPage, page => {
page.reviewTab.reviewOpenDialogLink.should('be.visible');
});
});

it('user should be able to write a review', () => {
at(ProductDetailPage, page => {
page.reviewTab.reviewOpenDialogLink.click();
page.reviewTab.reviewCreationForm.should('be.visible');

page.reviewTab.fillReviewForm({ rating: 2, title: 'Disappointment', content: 'Bad quality' });
page.reviewTab.submitReviewCreationForm();

page.infoText.should('contain', 'needs to be reviewed');
page.reviewTab.ownProductReview.should('exist');
page.reviewTab.ownProductReview.should('contain', 'Disappointment');
});
});

it('user should be able to delete his/her review', () => {
at(ProductDetailPage, page => {
page.reviewTab.deleteReviewLink.should('be.visible');
page.reviewTab.deleteOwnReview();

page.reviewTab.deleteReviewLink.should('not.exist');
page.reviewTab.reviewOpenDialogLink.should('be.visible');
});
});
});
16 changes: 16 additions & 0 deletions src/app/extensions/rating/facades/product-reviews.facade.ts
@@ -1,11 +1,15 @@
import { Injectable } from '@angular/core';
import { Store, select } from '@ngrx/store';

import { ProductReview, ProductReviewCreationType } from '../models/product-reviews/product-review.model';
import {
createProductReview,
deleteProductReview,
getProductReviewsBySku,
getProductReviewsError,
getProductReviewsLoading,
loadProductReviews,
resetProductReviewError,
} from '../store/product-reviews';

@Injectable({ providedIn: 'root' })
Expand All @@ -19,4 +23,16 @@ export class ProductReviewsFacade {
this.store.dispatch(loadProductReviews({ sku }));
return this.store.pipe(select(getProductReviewsBySku(sku)));
}

createProductReview(sku: string, review: ProductReviewCreationType) {
this.store.dispatch(createProductReview({ sku, review }));
}

deleteProductReview(sku: string, review: ProductReview): void {
this.store.dispatch(deleteProductReview({ sku, review }));
}

resetProductReviewError() {
this.store.dispatch(resetProductReviewError());
}
}
@@ -0,0 +1,10 @@
<div class="pt-1" data-testing-id="rating-stars-field">
<ng-container *ngFor="let fill of stars; index as i">
<a
[title]="'product.review.rating.tooltip' | translate: { '0': i + 1 }"
class="text-muted mt-4"
(click)="setStars(i + 1)"
><ish-product-rating-star [filled]="fill"></ish-product-rating-star
></a>
</ng-container>
</div>
@@ -0,0 +1,78 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormGroup } from '@angular/forms';
import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core';
import { TranslateModule } from '@ngx-translate/core';
import { MockComponent } from 'ng-mocks';

import { FormlyTestingComponentsModule } from 'ish-shared/formly/dev/testing/formly-testing-components.module';
import { FormlyTestingContainerComponent } from 'ish-shared/formly/dev/testing/formly-testing-container/formly-testing-container.component';

import { ProductRatingStarComponent } from '../../shared/product-rating-star/product-rating-star.component';

import { RatingStarsFieldComponent } from './rating-stars-field.component';

describe('Rating Stars Field Component', () => {
let component: FormlyTestingContainerComponent;
let fixture: ComponentFixture<FormlyTestingContainerComponent>;
let element: HTMLElement;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
FormlyModule.forRoot({
types: [
{
name: 'ish-rating-stars-field',
component: RatingStarsFieldComponent,
},
],
}),
FormlyTestingComponentsModule,
TranslateModule.forRoot(),
],
declarations: [MockComponent(ProductRatingStarComponent), RatingStarsFieldComponent],
}).compileComponents();
});

beforeEach(() => {
const testComponentInputs = {
fields: [
{
key: 'input',
type: 'ish-rating-stars-field',
templateOptions: {
label: 'test label',
required: true,
},
} as FormlyFieldConfig,
],
form: new FormGroup({}),
model: {
input: '',
},
};

fixture = TestBed.createComponent(FormlyTestingContainerComponent);
component = fixture.componentInstance;
element = fixture.nativeElement;

component.testComponentInputs = testComponentInputs;
});

it('should be created', () => {
expect(component).toBeTruthy();
expect(element).toBeTruthy();
expect(() => fixture.detectChanges()).not.toThrow();
});

it('should be rendered after creation', () => {
fixture.detectChanges();

expect(element.querySelectorAll('a')).toHaveLength(5);

element.querySelectorAll('a').forEach((el, key) => {
el.click();
expect(component.form.get('input').value).toEqual(key + 1);
});
});
});
@@ -0,0 +1,23 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FieldType, FieldTypeConfig } from '@ngx-formly/core';
import { range } from 'lodash-es';

import { RatingFilledType } from '../../shared/product-rating-star/product-rating-star.component';

/**
* Type that will render 5 stars to rate a product.
*/
@Component({
selector: 'ish-rating-stars-field',
templateUrl: './rating-stars-field.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RatingStarsFieldComponent extends FieldType<FieldTypeConfig> {
get stars(): RatingFilledType[] {
return range(1, 6).map(index => (index <= this.field?.formControl.value ? 'full' : 'empty'));
}

setStars(stars: number) {
this.field.formControl.setValue(stars);
}
}
Expand Up @@ -4,8 +4,12 @@ export interface ProductReview {
authorLastName: string;
title: string;
content: string;
creationDate: number;
rating: number;
showAuthorNameFlag: boolean;
localeID: string;
creationDate?: number;
showAuthorNameFlag?: boolean;
localeID?: string;
own?: boolean;
status?: 'NEW' | 'REJECTED';
}

export type ProductReviewCreationType = Pick<ProductReview, 'title' | 'content' | 'rating' | 'showAuthorNameFlag'>;

0 comments on commit 9ac2f85

Please sign in to comment.