Initial commit

This commit is contained in:
2026-07-27 18:01:07 +02:00
commit 0758b00d54
192 changed files with 45091 additions and 0 deletions

View 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": {}
}

View File

@@ -0,0 +1,2 @@
export * from './lib/product-list/product-list.component';
export * from './lib/feature-products.routes';

View File

@@ -0,0 +1,9 @@
import { Routes } from '@angular/router';
import { ProductListComponent } from './product-list/product-list.component';
export const featureProductsRoutes: Routes = [
{
path: '',
component: ProductListComponent,
},
];

View File

@@ -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
);
});
});

View File

@@ -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();
}
}
}

View 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()
);

View 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"]
}
}

View 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"]
}

View 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"]
}

View 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,
},
},
}));