Initial commit
This commit is contained in:
9
packages/shop/feature-products/project.json
Normal file
9
packages/shop/feature-products/project.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "feature-products",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "library",
|
||||
"sourceRoot": "packages/shop/feature-products/src",
|
||||
"prefix": "shop",
|
||||
"tags": ["scope:shop", "type:feature"],
|
||||
"targets": {}
|
||||
}
|
||||
2
packages/shop/feature-products/src/index.ts
Normal file
2
packages/shop/feature-products/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './lib/product-list/product-list.component';
|
||||
export * from './lib/feature-products.routes';
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { ProductListComponent } from './product-list/product-list.component';
|
||||
|
||||
export const featureProductsRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: ProductListComponent,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,134 @@
|
||||
import { TestBed, ComponentFixture } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { ProductListComponent } from './product-list.component';
|
||||
import { ProductsService } from '@org/shop/data';
|
||||
import { Product } from '@org/models';
|
||||
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||
|
||||
describe('ProductListComponent', () => {
|
||||
let component: ProductListComponent;
|
||||
let fixture: ComponentFixture<ProductListComponent>;
|
||||
let mockProductsService: Partial<ProductsService>;
|
||||
let mockRouter: Partial<Router>;
|
||||
|
||||
const mockProducts: Product[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Product 1',
|
||||
description: 'Description 1',
|
||||
price: 99.99,
|
||||
imageUrl: 'https://example.com/1.jpg',
|
||||
category: 'Electronics',
|
||||
inStock: true,
|
||||
rating: 4.5,
|
||||
reviewCount: 100,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Product 2',
|
||||
description: 'Description 2',
|
||||
price: 149.99,
|
||||
imageUrl: 'https://example.com/2.jpg',
|
||||
category: 'Clothing',
|
||||
inStock: false,
|
||||
rating: 4.0,
|
||||
reviewCount: 50,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
mockProductsService = {
|
||||
getProducts: vi.fn(),
|
||||
getCategories: vi.fn(),
|
||||
loading: vi.fn().mockReturnValue(false),
|
||||
error: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
|
||||
mockRouter = {
|
||||
navigate: vi.fn(),
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProductListComponent],
|
||||
providers: [
|
||||
{ provide: ProductsService, useValue: mockProductsService },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProductListComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load products and categories on init', () => {
|
||||
mockProductsService.getProducts.mockReturnValue(of({
|
||||
items: mockProducts,
|
||||
total: 2,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
totalPages: 1,
|
||||
}));
|
||||
mockProductsService.getCategories.mockReturnValue(of(['Electronics', 'Clothing']));
|
||||
|
||||
component.ngOnInit();
|
||||
|
||||
expect(mockProductsService.getProducts).toHaveBeenCalled();
|
||||
expect(mockProductsService.getCategories).toHaveBeenCalled();
|
||||
expect(component.products()).toEqual(mockProducts);
|
||||
});
|
||||
|
||||
it('should navigate to product detail when product is selected', () => {
|
||||
const product = mockProducts[0];
|
||||
|
||||
component.onProductSelect(product);
|
||||
|
||||
expect(mockRouter.navigate).toHaveBeenCalledWith(['/products', product.id]);
|
||||
});
|
||||
|
||||
it('should apply filters when search term changes', () => {
|
||||
mockProductsService.getProducts.mockReturnValue(of({
|
||||
items: mockProducts,
|
||||
total: 2,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
totalPages: 1,
|
||||
}));
|
||||
|
||||
component.searchTerm = 'Product 1';
|
||||
component.onSearchChange();
|
||||
|
||||
expect(mockProductsService.getProducts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
searchTerm: 'Product 1',
|
||||
}),
|
||||
1,
|
||||
12
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply filters when category changes', () => {
|
||||
mockProductsService.getProducts.mockReturnValue(of({
|
||||
items: [mockProducts[0]],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
totalPages: 1,
|
||||
}));
|
||||
|
||||
component.selectedCategory = 'Electronics';
|
||||
component.onFilterChange();
|
||||
|
||||
expect(mockProductsService.getProducts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
category: 'Electronics',
|
||||
}),
|
||||
1,
|
||||
12
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
import { Component, inject, signal, computed, OnInit, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { ProductsService } from '@org/shop/data';
|
||||
import { Product, ProductFilter } from '@org/models';
|
||||
import {
|
||||
ProductGridComponent,
|
||||
LoadingSpinnerComponent,
|
||||
ErrorMessageComponent,
|
||||
} from '@org/shop/shared-ui';
|
||||
|
||||
@Component({
|
||||
selector: 'shop-product-list',
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ProductGridComponent,
|
||||
LoadingSpinnerComponent,
|
||||
ErrorMessageComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="product-list-container">
|
||||
<header class="page-header">
|
||||
<h1>Our Products</h1>
|
||||
<p>Explore our wide selection of high-quality products</p>
|
||||
</header>
|
||||
|
||||
<div class="filters-section">
|
||||
<div class="search-box">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search products..."
|
||||
[(ngModel)]="searchTerm"
|
||||
(ngModelChange)="onSearchChange()"
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-controls">
|
||||
<select
|
||||
[(ngModel)]="selectedCategory"
|
||||
(ngModelChange)="onFilterChange()"
|
||||
class="filter-select"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
@for (category of categories(); track category) {
|
||||
<option [value]="category">{{ category }}</option>
|
||||
}
|
||||
</select>
|
||||
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="inStockOnly"
|
||||
(ngModelChange)="onFilterChange()"
|
||||
/>
|
||||
In Stock Only
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
<shop-loading-spinner />
|
||||
} @else if (error()) {
|
||||
<shop-error-message
|
||||
[message]="error() || undefined"
|
||||
(retry)="loadProducts()"
|
||||
/>
|
||||
} @else {
|
||||
<div class="results-info">
|
||||
Showing {{ products().length }} of {{ totalProducts() }} products
|
||||
</div>
|
||||
|
||||
<shop-product-grid
|
||||
[products]="products()"
|
||||
(productSelect)="onProductSelect($event)"
|
||||
/>
|
||||
|
||||
@if (hasMorePages()) {
|
||||
<div class="pagination">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
[disabled]="currentPage() === 1"
|
||||
(click)="previousPage()"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span class="page-info">
|
||||
Page {{ currentPage() }} of {{ totalPages() }}
|
||||
</span>
|
||||
<button
|
||||
class="btn-secondary"
|
||||
[disabled]="currentPage() === totalPages()"
|
||||
(click)="nextPage()"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.product-list-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 8px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
font-size: 1.1rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
background: #f8f9fa;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
font-size: 1rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 8px 16px;
|
||||
font-size: 1rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.checkbox-label input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.results-info {
|
||||
color: #666;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 48px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 8px 16px;
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #5a6268;
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.product-list-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProductListComponent implements OnInit {
|
||||
private readonly productsService = inject(ProductsService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
// State signals
|
||||
readonly products = signal<Product[]>([]);
|
||||
readonly totalProducts = signal(0);
|
||||
readonly currentPage = signal(1);
|
||||
readonly totalPages = signal(0);
|
||||
readonly categories = signal<string[]>([]);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
// Filter state
|
||||
searchTerm = '';
|
||||
selectedCategory = '';
|
||||
inStockOnly = false;
|
||||
|
||||
// Computed values
|
||||
readonly hasMorePages = computed(() => this.totalPages() > 1);
|
||||
|
||||
ngOnInit() {
|
||||
this.loadCategories();
|
||||
this.loadProducts();
|
||||
}
|
||||
|
||||
loadCategories() {
|
||||
this.productsService.getCategories().subscribe({
|
||||
next: (categories) => this.categories.set(categories),
|
||||
error: (err) => console.error('Error loading categories:', err),
|
||||
});
|
||||
}
|
||||
|
||||
loadProducts() {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
const filter: ProductFilter = {};
|
||||
|
||||
if (this.searchTerm) {
|
||||
filter.searchTerm = this.searchTerm;
|
||||
}
|
||||
if (this.selectedCategory) {
|
||||
filter.category = this.selectedCategory;
|
||||
}
|
||||
if (this.inStockOnly) {
|
||||
filter.inStock = true;
|
||||
}
|
||||
|
||||
this.productsService.getProducts(filter, this.currentPage(), 12).subscribe({
|
||||
next: (response) => {
|
||||
this.products.set(response.items);
|
||||
this.totalProducts.set(response.total);
|
||||
this.totalPages.set(response.totalPages);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set('Failed to load products. Please try again.');
|
||||
this.loading.set(false);
|
||||
console.error('Error loading products:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onSearchChange() {
|
||||
this.currentPage.set(1);
|
||||
this.loadProducts();
|
||||
}
|
||||
|
||||
onFilterChange() {
|
||||
this.currentPage.set(1);
|
||||
this.loadProducts();
|
||||
}
|
||||
|
||||
onProductSelect(product: Product) {
|
||||
this.router.navigate(['/products', product.id]);
|
||||
}
|
||||
|
||||
nextPage() {
|
||||
if (this.currentPage() < this.totalPages()) {
|
||||
this.currentPage.update(page => page + 1);
|
||||
this.loadProducts();
|
||||
}
|
||||
}
|
||||
|
||||
previousPage() {
|
||||
if (this.currentPage() > 1) {
|
||||
this.currentPage.update(page => page - 1);
|
||||
this.loadProducts();
|
||||
}
|
||||
}
|
||||
}
|
||||
13
packages/shop/feature-products/src/test-setup.ts
Normal file
13
packages/shop/feature-products/src/test-setup.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import '@angular/compiler';
|
||||
import '@analogjs/vitest-angular/setup-zone';
|
||||
|
||||
import {
|
||||
BrowserTestingModule,
|
||||
platformBrowserTesting,
|
||||
} from '@angular/platform-browser/testing';
|
||||
import { getTestBed } from '@angular/core/testing';
|
||||
|
||||
getTestBed().initTestEnvironment(
|
||||
BrowserTestingModule,
|
||||
platformBrowserTesting()
|
||||
);
|
||||
18
packages/shop/feature-products/tsconfig.json
Normal file
18
packages/shop/feature-products/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
],
|
||||
"compilerOptions": {
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["dom", "es2022"]
|
||||
}
|
||||
}
|
||||
12
packages/shop/feature-products/tsconfig.lib.json
Normal file
12
packages/shop/feature-products/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"exclude": ["src/**/*.spec.ts", "src/test-setup.ts"],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
30
packages/shop/feature-products/tsconfig.spec.json
Normal file
30
packages/shop/feature-products/tsconfig.spec.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
],
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": [
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx",
|
||||
"src/**/*.d.ts"
|
||||
],
|
||||
"files": ["src/test-setup.ts"]
|
||||
}
|
||||
28
packages/shop/feature-products/vite.config.mts
Normal file
28
packages/shop/feature-products/vite.config.mts
Normal file
@@ -0,0 +1,28 @@
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from 'vite';
|
||||
import angular from '@analogjs/vite-plugin-angular';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../../node_modules/.vite/packages/shop/feature-products',
|
||||
plugins: [angular(), tsconfigPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
test: {
|
||||
name: 'feature-products',
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
setupFiles: ['src/test-setup.ts'],
|
||||
reporters: ['default'],
|
||||
coverage: {
|
||||
reportsDirectory: '../../../coverage/packages/shop/feature-products',
|
||||
provider: 'v8' as const,
|
||||
},
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user