feat: implement product detail and list components with search functionality
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideRouter, withComponentInputBinding } from '@angular/router';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import {provideHttpClient} from '@angular/common/http';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(routes)
|
||||
provideRouter(routes, withComponentInputBinding()),
|
||||
provideHttpClient()
|
||||
]
|
||||
};
|
||||
|
||||
+12
-1
@@ -1,3 +1,14 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [];
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () => import('./features/product-list/product-list').then((m) => m.ProductList)
|
||||
},
|
||||
{
|
||||
path: 'product/:id',
|
||||
loadComponent: () =>
|
||||
import('./features/product-detail/product-detail').then((m) => m.ProductDetail)
|
||||
},
|
||||
{ path: '**', redirectTo: '' }
|
||||
];
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<div class="product-actions">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Colour</mat-label>
|
||||
<mat-select [value]="selectedColorCode()" (selectionChange)="selectedColorCode.set($event.value)">
|
||||
@for (color of options().colors; track color.code) {
|
||||
<mat-option [value]="color.code">{{ color.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Storage</mat-label>
|
||||
<mat-select
|
||||
[value]="selectedStorageCode()"
|
||||
(selectionChange)="selectedStorageCode.set($event.value)"
|
||||
>
|
||||
@for (storage of options().storages; track storage.code) {
|
||||
<mat-option [value]="storage.code">{{ storage.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
class="product-actions__submit"
|
||||
[disabled]="adding()"
|
||||
(click)="onAddToCart()"
|
||||
>
|
||||
Añadir
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
.product-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__submit {
|
||||
align-self: flex-start;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ProductActions } from './product-actions';
|
||||
|
||||
describe('ProductActions', () => {
|
||||
let component: ProductActions;
|
||||
let fixture: ComponentFixture<ProductActions>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProductActions]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProductActions);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Component, inject, input, linkedSignal, output, signal } from '@angular/core';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
|
||||
import { ProductOptions } from '../../../core/models/product.model';
|
||||
import { CartService } from '../../../core/services/cart.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-actions',
|
||||
imports: [MatFormFieldModule, MatSelectModule, MatButtonModule],
|
||||
templateUrl: './product-actions.html',
|
||||
styleUrl: './product-actions.scss'
|
||||
})
|
||||
export class ProductActions {
|
||||
private readonly cartService = inject(CartService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
|
||||
readonly productId = input.required<string>();
|
||||
readonly options = input.required<ProductOptions>();
|
||||
|
||||
readonly added = output<void>();
|
||||
|
||||
readonly selectedColorCode = linkedSignal(() => this.options().colors[0]?.code);
|
||||
|
||||
readonly selectedStorageCode = linkedSignal(() => this.options().storages[0]?.code);
|
||||
|
||||
readonly adding = signal(false);
|
||||
|
||||
async onAddToCart(): Promise<void> {
|
||||
const colorCode = this.selectedColorCode();
|
||||
const storageCode = this.selectedStorageCode();
|
||||
if (colorCode === undefined || storageCode === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.adding.set(true);
|
||||
try {
|
||||
await this.cartService.addToCart({
|
||||
id: this.productId(),
|
||||
colorCode,
|
||||
storageCode
|
||||
});
|
||||
this.snackBar.open('Product added to cart', 'Close', { duration: 3000 });
|
||||
this.added.emit();
|
||||
} catch {
|
||||
this.snackBar.open('We couldn\'t add the product to the cart', 'Close', {
|
||||
duration: 3000
|
||||
});
|
||||
} finally {
|
||||
this.adding.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<dl class="product-description">
|
||||
@for (row of rows(); track row.label) {
|
||||
<div class="product-description__row">
|
||||
<dt>{{ row.label }}</dt>
|
||||
<dd>{{ row.value }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
@@ -0,0 +1,21 @@
|
||||
.product-description {
|
||||
margin: 0;
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
|
||||
dt {
|
||||
font-weight: 600;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ProductDescription } from './product-description';
|
||||
|
||||
describe('ProductDescription', () => {
|
||||
let component: ProductDescription;
|
||||
let fixture: ComponentFixture<ProductDescription>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProductDescription]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProductDescription);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
|
||||
import { ProductDetail } from '../../../core/models/product.model';
|
||||
|
||||
// used to renderize rows on for each description value
|
||||
interface DescriptionRow {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-description',
|
||||
templateUrl: './product-description.html',
|
||||
styleUrl: './product-description.scss'
|
||||
})
|
||||
export class ProductDescription {
|
||||
|
||||
readonly product = input.required<ProductDetail>();
|
||||
|
||||
readonly rows = computed<DescriptionRow[]>(() => {
|
||||
const p = this.product();
|
||||
const cameras = [...(p.primaryCamera ?? []), ...(p.secondaryCmera ?? [])];
|
||||
|
||||
const candidates: DescriptionRow[] = [
|
||||
{ label: 'Brand', value: p.brand },
|
||||
{ label: 'Model', value: p.model },
|
||||
{ label: 'Price', value: `${p.price} €` },
|
||||
{ label: 'CPU', value: p.cpu ?? '' },
|
||||
{ label: 'RAM', value: p.ram ?? '' },
|
||||
{ label: 'OS', value: p.os ?? '' },
|
||||
{ label: 'Display Type', value: p.displayType ?? '' },
|
||||
// Note: names on the API "displayResolution"/"displaySize" are swapped compared to their real content.
|
||||
{ label: 'Screen size', value: p.displayResolution ?? '' },
|
||||
{ label: 'Resolution', value: p.displaySize ?? '' },
|
||||
{ label: 'Battery', value: p.battery ?? '' },
|
||||
{ label: 'Cameras', value: cameras.join(', ') },
|
||||
{ label: 'Dimensions', value: p.dimentions ?? '' },
|
||||
{ label: 'Weight', value: p.weight ?? '' }
|
||||
];
|
||||
|
||||
return candidates.filter((row) => row.value !== '');
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<section class="product-detail">
|
||||
<a mat-button routerLink="/" class="product-detail__back">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
Go back to the list
|
||||
</a>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="product-detail__loading">
|
||||
<mat-progress-spinner mode="indeterminate" diameter="48"></mat-progress-spinner>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<p class="product-detail__error">{{ error() }}</p>
|
||||
} @else if (product(); as product) {
|
||||
<div class="product-detail__columns">
|
||||
<app-product-image [imgUrl]="product.imgUrl" [alt]="product.brand + ' ' + product.model" />
|
||||
|
||||
<div class="product-detail__info">
|
||||
<h1>{{ product.brand }} {{ product.model }}</h1>
|
||||
<app-product-description [product]="product" />
|
||||
<app-product-actions [productId]="product.id" [options]="product.options" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,34 @@
|
||||
.product-detail {
|
||||
&__back {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
&__columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 360px) 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
&__info {
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
&__error {
|
||||
text-align: center;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
&__columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ProductDetail } from './product-detail';
|
||||
|
||||
describe('ProductDetail', () => {
|
||||
let component: ProductDetail;
|
||||
let fixture: ComponentFixture<ProductDetail>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProductDetail]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProductDetail);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Component, effect, inject, input, signal } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
import { ProductService } from '../../core/services/product.service';
|
||||
import { ProductDetail as ProductDetailModel } from '../../core/models/product.model';
|
||||
import { ProductImage } from './product-image/product-image';
|
||||
import { ProductDescription } from './product-description/product-description';
|
||||
import { ProductActions } from './product-actions/product-actions';
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-detail',
|
||||
imports: [
|
||||
RouterLink,
|
||||
MatProgressSpinnerModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
ProductImage,
|
||||
ProductDescription,
|
||||
ProductActions
|
||||
],
|
||||
templateUrl: './product-detail.html',
|
||||
styleUrl: './product-detail.scss'
|
||||
})
|
||||
export class ProductDetail {
|
||||
private readonly productService = inject(ProductService);
|
||||
|
||||
//product id
|
||||
readonly id = input.required<string>();
|
||||
|
||||
// product detail
|
||||
readonly product = signal<ProductDetailModel | null>(null);
|
||||
|
||||
readonly loading = signal(true);
|
||||
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
effect((onCleanup) => {
|
||||
const id = this.id();
|
||||
let cancelled = false;
|
||||
onCleanup(() => {
|
||||
cancelled = true;
|
||||
});
|
||||
this.loadProduct(id, () => cancelled);
|
||||
});
|
||||
}
|
||||
|
||||
// Loads the product detail from the API and updates the signals accordingly.
|
||||
private async loadProduct(id: string, isCancelled: () => boolean): Promise<void> {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
try {
|
||||
const product = await this.productService.getProductDetail(id);
|
||||
if (!isCancelled()) {
|
||||
this.product.set(product);
|
||||
}
|
||||
} catch {
|
||||
if (!isCancelled()) {
|
||||
this.error.set('Not able to load de item detail.');
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled()) {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<img class="product-image" [src]="imgUrl()" [alt]="alt()" />
|
||||
@@ -0,0 +1,8 @@
|
||||
.product-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: contain;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ProductImage } from './product-image';
|
||||
|
||||
describe('ProductImage', () => {
|
||||
let component: ProductImage;
|
||||
let fixture: ComponentFixture<ProductImage>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProductImage]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProductImage);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-image',
|
||||
templateUrl: './product-image.html',
|
||||
styleUrl: './product-image.scss'
|
||||
})
|
||||
export class ProductImage {
|
||||
readonly imgUrl = input.required<string>();
|
||||
readonly alt = input.required<string>();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<mat-card
|
||||
class="product-item"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
[attr.aria-label]="product().brand + ' ' + product().model"
|
||||
(click)="onSelect()"
|
||||
(keyup.enter)="onSelect()"
|
||||
>
|
||||
<img
|
||||
class="product-item__image"
|
||||
[src]="product().imgUrl"
|
||||
[alt]="product().brand + ' ' + product().model"
|
||||
/>
|
||||
<mat-card-content>
|
||||
<p class="product-item__brand">{{ product().brand }}</p>
|
||||
<p class="product-item__model">{{ product().model }}</p>
|
||||
<p class="product-item__price">{{ product().price }} €</p>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
@@ -0,0 +1,40 @@
|
||||
.product-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&__image {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: contain;
|
||||
box-sizing: border-box;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
&__brand {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
&__model {
|
||||
margin: 0.25rem 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__price {
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ProductItem } from './product-item';
|
||||
|
||||
describe('ProductItem', () => {
|
||||
let component: ProductItem;
|
||||
let fixture: ComponentFixture<ProductItem>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProductItem]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProductItem);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
|
||||
import { Product } from '../../../core/models/product.model';
|
||||
|
||||
// Item visor
|
||||
@Component({
|
||||
selector: 'app-product-item',
|
||||
imports: [MatCardModule],
|
||||
templateUrl: './product-item.html',
|
||||
styleUrl: './product-item.scss'
|
||||
})
|
||||
export class ProductItem {
|
||||
// product to show in the card
|
||||
readonly product = input.required<Product>();
|
||||
|
||||
// Event emited when product is selected.
|
||||
readonly productSelected = output<Product>();
|
||||
|
||||
onSelect(): void {
|
||||
this.productSelected.emit(this.product());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<section class="product-list">
|
||||
<header class="product-list__toolbar">
|
||||
<h1>Product list</h1>
|
||||
<app-product-search (searchChange)="onSearchTermChange($event)"></app-product-search>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="product-list__loading">
|
||||
<mat-progress-spinner mode="indeterminate" diameter="48"></mat-progress-spinner>
|
||||
</div>
|
||||
} @else if (error()) {
|
||||
<p class="product-list__error">{{ error() }}</p>
|
||||
} @else if (filteredProducts().length === 0) {
|
||||
<p class="product-list__empty">No products found.</p>
|
||||
} @else {
|
||||
<div class="product-list__scroll" #scrollContainer (scroll)="onScroll($event)">
|
||||
<div class="product-list__grid">
|
||||
@for (product of visibleProducts(); track product.id) {
|
||||
<app-product-item
|
||||
[product]="product"
|
||||
(productSelected)="onSelectProduct($event)"
|
||||
></app-product-item>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,72 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.product-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
|
||||
&__toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
|
||||
&__scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
&__loading,
|
||||
&__error,
|
||||
&__empty {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__error,
|
||||
&__empty {
|
||||
text-align: center;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
// max 4 elements
|
||||
@media (max-width: 1024px) {
|
||||
&__grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
&__grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
&__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ProductList } from './product-list';
|
||||
|
||||
describe('ProductList', () => {
|
||||
let component: ProductList;
|
||||
let fixture: ComponentFixture<ProductList>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProductList]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProductList);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
afterRenderEffect,
|
||||
computed,
|
||||
inject,
|
||||
signal,
|
||||
viewChild, OnInit
|
||||
} from '@angular/core';
|
||||
|
||||
import { Router } from '@angular/router';
|
||||
import {MatProgressSpinner, MatProgressSpinnerModule} from '@angular/material/progress-spinner';
|
||||
|
||||
import { ProductService } from '../../core/services/product.service';
|
||||
import { Product } from '../../core/models/product.model';
|
||||
import { ProductSearch } from './product-search/product-search';
|
||||
import { ProductItem } from './product-item/product-item';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
@Component({
|
||||
selector: 'app-product-list',
|
||||
imports: [
|
||||
MatProgressSpinner,
|
||||
ProductItem,
|
||||
ProductSearch
|
||||
],
|
||||
templateUrl: './product-list.html',
|
||||
styleUrl: './product-list.scss',
|
||||
})
|
||||
export class ProductList implements OnInit {
|
||||
|
||||
private readonly productService = inject(ProductService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
// Scroll list where items will be displayed
|
||||
private readonly scrollContainer = viewChild<ElementRef<HTMLElement>>('scrollContainer');
|
||||
|
||||
// raw list of items retrieved by api call
|
||||
private readonly allProducts = signal<Product[]>([]);
|
||||
|
||||
// search tearm on the search input
|
||||
readonly searchTerm = signal('');
|
||||
|
||||
readonly loading = signal(true);
|
||||
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
// products on the infinite scroll list that are currently visible
|
||||
readonly visibleCount = signal(PAGE_SIZE);
|
||||
|
||||
//filtered products
|
||||
readonly filteredProducts = computed(() => {
|
||||
const term = this.searchTerm().trim().toLowerCase();
|
||||
if (!term) {
|
||||
return this.allProducts();
|
||||
}
|
||||
return this.allProducts().filter(
|
||||
(product) =>
|
||||
product.brand.toLowerCase().includes(term) || product.model.toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
|
||||
// products that are currently visible on the list
|
||||
readonly visibleProducts = computed(() => this.filteredProducts().slice(0, this.visibleCount()));
|
||||
|
||||
readonly hasMore = computed(() => this.visibleCount() < this.filteredProducts().length);
|
||||
|
||||
constructor() {
|
||||
// small workaround to fix scroll heigh disaligned
|
||||
afterRenderEffect(() => {
|
||||
this.visibleProducts();
|
||||
const el = this.scrollContainer()?.nativeElement;
|
||||
if (el && this.hasMore() && el.scrollHeight <= el.clientHeight) {
|
||||
this.loadMore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.loadProducts();
|
||||
|
||||
|
||||
}
|
||||
|
||||
onSearchTermChange(term: string): void {
|
||||
this.searchTerm.set(term);
|
||||
this.visibleCount.set(PAGE_SIZE);
|
||||
}
|
||||
|
||||
// navigate to product detail page when users clic and item
|
||||
onSelectProduct(product: Product): void {
|
||||
this.router.navigate(['/product', product.id]);
|
||||
}
|
||||
|
||||
// charge items when scroll is close to the end of the list and still pending items to show
|
||||
onScroll(event: Event): void {
|
||||
const el = event.target as HTMLElement;
|
||||
const scrollThresholdPx = 200;
|
||||
const nearBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - scrollThresholdPx;
|
||||
if (nearBottom && this.hasMore()) {
|
||||
this.loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
// load products from the api and set the allProducts signal
|
||||
private async loadProducts(): Promise<void> {
|
||||
try {
|
||||
const products = await this.productService.getProducts();
|
||||
this.allProducts.set(products);
|
||||
} catch {
|
||||
this.error.set('No se ha podido cargar el listado de productos.');
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
// load more products to the visible list when user scrolls down
|
||||
private loadMore(): void {
|
||||
this.visibleCount.update((count) =>
|
||||
Math.min(count + PAGE_SIZE, this.filteredProducts().length)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<mat-form-field appearance="outline" class="product-search" subscriptSizing="dynamic">
|
||||
<mat-label>Find by brand or model</mat-label>
|
||||
<input matInput type="text" (input)="onInput($event)" placeholder="Samsung, iPhone 12..." />
|
||||
<mat-icon matSuffix>search</mat-icon>
|
||||
</mat-form-field>
|
||||
@@ -0,0 +1,4 @@
|
||||
.product-search {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ProductSearch } from './product-search';
|
||||
|
||||
describe('ProductSearch', () => {
|
||||
let component: ProductSearch;
|
||||
let fixture: ComponentFixture<ProductSearch>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProductSearch]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProductSearch);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Component, output } from '@angular/core';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
//search bar
|
||||
@Component({
|
||||
selector: 'app-product-search',
|
||||
imports: [MatFormFieldModule, MatInputModule, MatIconModule],
|
||||
templateUrl: './product-search.html',
|
||||
styleUrl: './product-search.scss'
|
||||
})
|
||||
export class ProductSearch {
|
||||
|
||||
// Emits the search event
|
||||
readonly searchChange = output<string>();
|
||||
|
||||
// Extracts the value from the input and emits it to the parent component
|
||||
onInput(event: Event): void {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
this.searchChange.emit(value);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user