Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add product card and tests #35

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
<div class="stage">
<div class="products" *ngIf="products$ | async; let products">
<pre *ngFor="let product of products.content">{{ product | json }}</pre>
<div *ngIf="error$ | async as error" class="error">
{{ error }}
</div>
<button class="more">Load more</button>
</div>
<div class="product-list">
<app-product-card
*ngFor="let product of products$ | async"
[product]="product"
></app-product-card>
</div>
<button
*ngIf="(more$ | async) !== false"
class="more"
(click)="loadMore()"
[disabled]="loading$ | async"
>
<ng-container *ngIf="loading$ | async; else buttonContent">
Loading...
</ng-container>
<ng-template #buttonContent>
<ng-container *ngIf="(error$ | async) !== null; else loadMoreText">
Try again
</ng-container>
<ng-template #loadMoreText> Load more </ng-template>
</ng-template>
</button>
</div>
14 changes: 10 additions & 4 deletions src/app/app.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -26,4 +32,4 @@ pre {
&:hover, &:focus {
background-color: #ebecf0;
}
}
}
149 changes: 143 additions & 6 deletions src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Product[]>([]);
_loading$ = new BehaviorSubject<boolean>(false);
_error$ = new BehaviorSubject<string | null>(null);
_more$ = new BehaviorSubject<boolean>(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<AppComponent>;
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();
}));
});
19 changes: 15 additions & 4 deletions src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
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',
templateUrl: './app.component.html',
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<Page<Product>> = this.productService.get(0);
}
7 changes: 4 additions & 3 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<div class="card" (click)="openProductUrl(product.url)">
<div class="card-image" [style.background-image]="getImageUrl(product.image)">
<div *ngIf="!product?.image" class="placeholder">Image not available</div>
</div>
<div class="card-content">
<h3 class="card-title">{{ product.title }}</h3>
<p class="card-description">{{ product.description }}</p>
<ul class="card-categories">
<li *ngFor="let category of product.categories">{{ category }}</li>
</ul>
</div>
</div>
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
}
Loading