diff --git a/src/app/app.component.html b/src/app/app.component.html index 0c03e65..915524e 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,6 +1,27 @@
-
-
{{ product | json }}
+
+ {{ error }}
- -
\ No newline at end of file +
+ +
+ +
diff --git a/src/app/app.component.scss b/src/app/app.component.scss index a1e7d11..36aafdb 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -6,10 +6,16 @@ padding: 4rem; background-color: #fff; } +.error { + color: red; + text-align: center; + margin: 1rem 0; +} -pre { - overflow: auto; - background-color: #f2f4f7; +.product-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); + gap: 1rem; padding: 1rem; } @@ -26,4 +32,4 @@ pre { &:hover, &:focus { background-color: #ebecf0; } -} \ No newline at end of file +} diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index 77ac082..e0b8d92 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -1,16 +1,153 @@ -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BehaviorSubject } from 'rxjs'; import { AppComponent } from './app.component'; +import { ProductStateService } from './products/product-state.service'; +import { Product } from './products/product'; +import { ProductCardComponent } from './products/components/product-card/product-card/product-card.component'; + +class MockProductStateService { + _products$ = new BehaviorSubject([]); + _loading$ = new BehaviorSubject(false); + _error$ = new BehaviorSubject(null); + _more$ = new BehaviorSubject(true); + + products$ = this._products$.asObservable(); + loading$ = this._loading$.asObservable(); + error$ = this._error$.asObservable(); + more$ = this._more$.asObservable(); + + loadMore() { + this._loading$.next(true); + setTimeout(() => { + const newProducts: Product[] = [ + { url: 'http://example.com/2', title: 'Product 2', description: 'Description 2', image: 'http://example.com/image2.jpg', categories: ['Category2'] } + ]; + this._products$.next(this._products$.getValue().concat(newProducts)); + this._loading$.next(false); + this._more$.next(false); + }, 1000); + } + + triggerError() { + this._loading$.next(false); + this._error$.next('Error loading products'); + } + + returnNoData() { + this._loading$.next(false); + this._more$.next(false); + } +} describe('AppComponent', () => { + let component: AppComponent; + let fixture: ComponentFixture; + let productStateService: MockProductStateService; + beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [AppComponent] + declarations: [AppComponent], + imports: [ProductCardComponent], + providers: [ + { provide: ProductStateService, useClass: MockProductStateService } + ] }).compileComponents(); }); - it('should create the app', () => { - const fixture = TestBed.createComponent(AppComponent); - const app = fixture.componentInstance; - expect(app).toBeTruthy(); + beforeEach(() => { + fixture = TestBed.createComponent(AppComponent); + component = fixture.componentInstance; + productStateService = TestBed.inject(ProductStateService) as unknown as MockProductStateService; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); }); + + + it('should call ProductStateService.loadMore method when loadMore is called', () => { + spyOn(productStateService, 'loadMore').and.callThrough(); + + component.loadMore(); + + expect(productStateService.loadMore).toHaveBeenCalled(); + }); + + it('should disable "Load more" button when loading', fakeAsync(() => { + productStateService._loading$.next(true); + fixture.detectChanges(); + tick(); + + const buttonElement = fixture.debugElement.query(By.css('.more')).nativeElement; + expect(buttonElement.disabled).toBeTrue(); + })); + + it('should show "Loading..." text on button when loading', fakeAsync(() => { + productStateService._loading$.next(true); + fixture.detectChanges(); + tick(); + + const buttonElement = fixture.debugElement.query(By.css('.more')).nativeElement; + expect(buttonElement.textContent).toContain('Loading...'); + })); + + it('should show "Load more" text on button when not loading', fakeAsync(() => { + productStateService._loading$.next(false); + fixture.detectChanges(); + tick(); + + const buttonElement = fixture.debugElement.query(By.css('.more')).nativeElement; + expect(buttonElement.textContent).toContain('Load more'); + })); + + it('should show "Try again" text on button when there is an error', fakeAsync(() => { + productStateService.triggerError(); + fixture.detectChanges(); + tick(); + + const buttonElement = fixture.debugElement.query(By.css('.more')).nativeElement; + expect(buttonElement.textContent).toContain('Try again'); + })); + + it('should show error message when there is an error', fakeAsync(() => { + const errorMessage = 'Error loading products'; + productStateService._error$.next(errorMessage); + fixture.detectChanges(); + tick(); + + const errorElement = fixture.debugElement.query(By.css('.error')).nativeElement; + expect(errorElement.textContent).toContain(errorMessage); + })); + + it('should load additional products when "Load more" button is clicked', fakeAsync(() => { + const initialProducts: Product[] = [ + { url: 'http://example.com/1', title: 'Product 1', description: 'Description 1', image: 'http://example.com/image1.jpg', categories: ['Category1'] }, + { url: 'http://example.com/2', title: 'Product 2', description: 'Description 2', image: 'http://example.com/image2.jpg', categories: ['Category2'] } + ]; + + productStateService._products$.next(initialProducts); + fixture.detectChanges(); + tick(); + + const buttonElement = fixture.debugElement.query(By.css('.more')).nativeElement; + buttonElement.click(); + tick(1000); + + fixture.detectChanges(); + tick(); + + const productElements = fixture.debugElement.queryAll(By.css('app-product-card')); + expect(productElements.length).toBe(2); + })); + + it('should hide "Load more" button when service returns no data', fakeAsync(() => { + productStateService.returnNoData(); + fixture.detectChanges(); + tick(); + + const buttonElement = fixture.debugElement.query(By.css('.more')); + expect(buttonElement).toBeNull(); + })); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 06f5ba9..b672fda 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,8 +1,8 @@ import { Component } from '@angular/core'; import { Observable } from 'rxjs'; import { Page } from './products/page'; -import { Product } from './products/product'; -import { ProductService } from './products/product.service'; + +import { ProductStateService } from './products/product-state.service'; @Component({ selector: 'app-root', @@ -10,7 +10,18 @@ import { ProductService } from './products/product.service'; styleUrls: ['./app.component.scss'] }) export class AppComponent { - constructor(private readonly productService: ProductService) {} + products$ = this.productStateService.products$; + loading$ = this.productStateService.loading$; + error$ = this.productStateService.error$; + more$ = this.productStateService.more$; + + constructor(private productStateService: ProductStateService) {} + ngOnInit() { + this.productStateService.loadMore(); + } + + loadMore() { + this.productStateService.loadMore(); + } - readonly products$: Observable> = this.productService.get(0); } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 5e2c2c3..bfcd600 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -2,10 +2,11 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { AppComponent } from './app.component'; +import { ProductCardComponent } from "./products/components/product-card/product-card/product-card.component"; @NgModule({ - imports: [BrowserModule, CommonModule], - declarations: [AppComponent], - bootstrap: [AppComponent] + imports: [BrowserModule, CommonModule, ProductCardComponent], + declarations: [AppComponent ], + bootstrap: [AppComponent, ] }) export class AppModule {} diff --git a/src/app/products/components/product-card/product-card/product-card.component.html b/src/app/products/components/product-card/product-card/product-card.component.html new file mode 100644 index 0000000..64353d5 --- /dev/null +++ b/src/app/products/components/product-card/product-card/product-card.component.html @@ -0,0 +1,12 @@ +
+
+
Image not available
+
+
+

{{ product.title }}

+

{{ product.description }}

+
    +
  • {{ category }}
  • +
+
+
diff --git a/src/app/products/components/product-card/product-card/product-card.component.scss b/src/app/products/components/product-card/product-card/product-card.component.scss new file mode 100644 index 0000000..ebda59b --- /dev/null +++ b/src/app/products/components/product-card/product-card/product-card.component.scss @@ -0,0 +1,89 @@ +.card { + display: flex; + flex-direction: column; + border: 1px solid #ddd; + border-radius: 8px; + overflow: hidden; + cursor: pointer; + transition: box-shadow 0.3s, transform 0.3s; + background: #fff; + flex: 1 1 auto; + display: flex; + flex-direction: column; + height: 100%; + + &:hover { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); + } + + .card-image { + width: 100%; + padding-top: 50%; // 2:1 Aspect Ratio + background-size: cover; + background-position: center; + position: relative; + + .placeholder { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: center; + align-items: center; + color: #999; + background: #f4f4f4; + } + } + + .card-content { + display: flex; + flex-direction: column; + flex: 1; + padding: 1rem; + min-height: 200px; + display: flex; + flex-direction: column; + justify-content: space-between; + + .card-title { + font-size: 1.2rem; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .card-description { + font-size: 1rem; + color: #666; + font-weight: 400; + margin: 0.5rem 0; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 3; // Limit to three lines + -webkit-box-orient: vertical; + text-overflow: ellipsis; + } + + .card-categories { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + list-style: none; + padding: 0; + margin: 0; + font-size: 0.8rem; + color: #7f7f7f; + margin-top: auto; + + li { + padding: 0.2rem 0.5rem; + border-radius: 4px; + border: 1px solid; + } + } + } +} diff --git a/src/app/products/components/product-card/product-card/product-card.component.spec.ts b/src/app/products/components/product-card/product-card/product-card.component.spec.ts new file mode 100644 index 0000000..079440c --- /dev/null +++ b/src/app/products/components/product-card/product-card/product-card.component.spec.ts @@ -0,0 +1,77 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { ProductCardComponent } from './product-card.component'; +import { Product } from 'src/app/products/product'; + +describe('ProductCardComponent', () => { + let component: ProductCardComponent; + let fixture: ComponentFixture; + + const mockProduct: Product = { + url: 'http://example.com', + title: 'Test Product', + description: 'This is a test product description. It is supposed to be quite long to test text overflow.', + image: 'http://example.com/image.jpg', + categories: ['Category1', 'Category2', 'Category3'] + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ProductCardComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ProductCardComponent); + component = fixture.componentInstance; + + component.product = mockProduct; + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should display the product title', () => { + const titleElement: HTMLElement = fixture.debugElement.query(By.css('.card-title')).nativeElement; + expect(titleElement.textContent).toContain(mockProduct.title); + }); + + it('should display the product description', () => { + const descriptionElement: HTMLElement = fixture.debugElement.query(By.css('.card-description')).nativeElement; + expect(descriptionElement.textContent).toContain(mockProduct.description); + }); + + it('should display the product categories', () => { + const categoryElements = fixture.debugElement.queryAll(By.css('.card-categories li')); + expect(categoryElements.length).toBe(mockProduct.categories.length); + mockProduct.categories.forEach((category, index) => { + expect(categoryElements[index].nativeElement.textContent).toContain(category); + }); + }); + + it('should display an image if provided', () => { + const imageElement = fixture.debugElement.query(By.css('.card-image')); + expect(imageElement.nativeElement.style.backgroundImage).toContain(mockProduct.image ?? 'none'); + }); + + it('should show "Image not available" if no image is provided', () => { + component.product.image = null; + fixture.detectChanges(); + + const placeholderElement: HTMLElement = fixture.debugElement.query(By.css('.card-image .placeholder')).nativeElement; + expect(placeholderElement).toBeTruthy(); + expect(placeholderElement.textContent).toContain('Image not available'); + }); + + it('should open the product URL in a new tab when the card is clicked', () => { + spyOn(window, 'open'); + + const cardElement = fixture.debugElement.query(By.css('.card')).nativeElement; + cardElement.click(); + + expect(window.open).toHaveBeenCalledWith(mockProduct.url, '_blank'); + }); +}); diff --git a/src/app/products/components/product-card/product-card/product-card.component.ts b/src/app/products/components/product-card/product-card/product-card.component.ts new file mode 100644 index 0000000..8c51e16 --- /dev/null +++ b/src/app/products/components/product-card/product-card/product-card.component.ts @@ -0,0 +1,22 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input } from '@angular/core'; +import { Product } from 'src/app/products/product'; + +@Component({ + selector: 'app-product-card', + standalone: true, + imports: [CommonModule], + templateUrl: './product-card.component.html', + styleUrl: './product-card.component.scss' +}) +export class ProductCardComponent { + @Input() product!: Product; + + getImageUrl(image: string | null | undefined): string { + return image ? `url(${image})` : 'none'; + } + + openProductUrl(url: string): void { + window.open(url, '_blank'); + } +} diff --git a/src/app/products/product-state.service.ts b/src/app/products/product-state.service.ts new file mode 100644 index 0000000..0d366e9 --- /dev/null +++ b/src/app/products/product-state.service.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@angular/core'; +import { BehaviorSubject, throwError } from 'rxjs'; +import { catchError, finalize, tap } from 'rxjs/operators'; +import { ProductService } from './product.service'; +import { Product } from './product'; +import { Page } from './page'; + +@Injectable({ + providedIn: 'root', +}) +export class ProductStateService { + private productsSubject = new BehaviorSubject([]); + private loadingSubject = new BehaviorSubject(false); + private errorSubject = new BehaviorSubject(null); + private moreSubject = new BehaviorSubject(true); + private page = 0; + + products$ = this.productsSubject.asObservable(); + loading$ = this.loadingSubject.asObservable(); + error$ = this.errorSubject.asObservable(); + more$ = this.moreSubject.asObservable(); + + constructor(private productService: ProductService) {} + + loadMore() { + if (this.loadingSubject.value) return; + + this.loadingSubject.next(true); + this.errorSubject.next(null); + + this.productService + .get(this.page) + .pipe( + tap((page: Page) => { + if (page.content.length === 0) { + this.moreSubject.next(false); + } else { + this.productsSubject.next([ + ...this.productsSubject.value, + ...page.content, + ]); + this.moreSubject.next(page.more); + this.page++; + } + }), + catchError((error) => { + this.errorSubject.next('Error loading products'); + return throwError(() => new Error(error)); + }), + finalize(() => { + this.loadingSubject.next(false); + }) + ) + .subscribe(); + } +}