Initial commit
This commit is contained in:
11
packages/api/products/README.md
Normal file
11
packages/api/products/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# products
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Building
|
||||
|
||||
Run `nx build products` to build the library.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test products` to execute the unit tests via [Jest](https://jestjs.io).
|
||||
19
packages/api/products/eslint.config.mjs
Normal file
19
packages/api/products/eslint.config.mjs
Normal file
@@ -0,0 +1,19 @@
|
||||
import baseConfig from '../../../eslint.config.mjs';
|
||||
|
||||
export default [
|
||||
...baseConfig,
|
||||
{
|
||||
files: ['**/*.json'],
|
||||
rules: {
|
||||
'@nx/dependency-checks': [
|
||||
'error',
|
||||
{
|
||||
ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'],
|
||||
},
|
||||
],
|
||||
},
|
||||
languageOptions: {
|
||||
parser: await import('jsonc-eslint-parser'),
|
||||
},
|
||||
},
|
||||
];
|
||||
11
packages/api/products/package.json
Normal file
11
packages/api/products/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@org/products",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "./src/index.js",
|
||||
"types": "./src/index.d.ts",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
}
|
||||
}
|
||||
8
packages/api/products/project.json
Normal file
8
packages/api/products/project.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "products",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "packages/api/products/src",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:api"],
|
||||
"targets": {}
|
||||
}
|
||||
1
packages/api/products/src/index.ts
Normal file
1
packages/api/products/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/products.service';
|
||||
107
packages/api/products/src/lib/products.service.spec.ts
Normal file
107
packages/api/products/src/lib/products.service.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { ProductsService } from './products.service';
|
||||
import { ProductFilter } from '@org/models';
|
||||
|
||||
describe('ProductsService', () => {
|
||||
let service: ProductsService;
|
||||
|
||||
beforeEach(() => {
|
||||
service = new ProductsService();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('getAllProducts', () => {
|
||||
it('should return paginated products with default pagination', () => {
|
||||
const result = service.getAllProducts();
|
||||
|
||||
expect(result).toHaveProperty('items');
|
||||
expect(result).toHaveProperty('total');
|
||||
expect(result).toHaveProperty('page');
|
||||
expect(result).toHaveProperty('pageSize');
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.pageSize).toBe(12);
|
||||
expect(result.items.length).toBeLessThanOrEqual(12);
|
||||
});
|
||||
|
||||
it('should filter products by category', () => {
|
||||
const filter: ProductFilter = { category: 'Electronics' };
|
||||
const result = service.getAllProducts(filter);
|
||||
|
||||
result.items.forEach((product) => {
|
||||
expect(product.category).toBe('Electronics');
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter products by price range', () => {
|
||||
const filter: ProductFilter = { minPrice: 50, maxPrice: 150 };
|
||||
const result = service.getAllProducts(filter);
|
||||
|
||||
result.items.forEach((product) => {
|
||||
expect(product.price).toBeGreaterThanOrEqual(50);
|
||||
expect(product.price).toBeLessThanOrEqual(150);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle search term filtering', () => {
|
||||
const filter: ProductFilter = { searchTerm: 'Product 1' };
|
||||
const result = service.getAllProducts(filter);
|
||||
|
||||
result.items.forEach((product) => {
|
||||
const matchesSearch =
|
||||
product.name.toLowerCase().includes('product 1') ||
|
||||
product.description.toLowerCase().includes('product 1');
|
||||
expect(matchesSearch).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should paginate results correctly', () => {
|
||||
const page1 = service.getAllProducts(undefined, 1, 5);
|
||||
const page2 = service.getAllProducts(undefined, 2, 5);
|
||||
|
||||
expect(page1.items.length).toBeLessThanOrEqual(5);
|
||||
expect(page2.items.length).toBeLessThanOrEqual(5);
|
||||
expect(page1.items[0]?.id).not.toBe(page2.items[0]?.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProductById', () => {
|
||||
it('should return a product by id', () => {
|
||||
const product = service.getProductById('prod-1');
|
||||
|
||||
expect(product).toBeTruthy();
|
||||
expect(product?.id).toBe('prod-1');
|
||||
});
|
||||
|
||||
it('should return null for non-existent id', () => {
|
||||
const product = service.getProductById('non-existent');
|
||||
|
||||
expect(product).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCategories', () => {
|
||||
it('should return unique categories', () => {
|
||||
const categories = service.getCategories();
|
||||
|
||||
expect(categories).toBeTruthy();
|
||||
expect(Array.isArray(categories)).toBe(true);
|
||||
expect(categories.length).toBeGreaterThan(0);
|
||||
|
||||
const uniqueCategories = new Set(categories);
|
||||
expect(uniqueCategories.size).toBe(categories.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPriceRange', () => {
|
||||
it('should return min and max prices', () => {
|
||||
const range = service.getPriceRange();
|
||||
|
||||
expect(range).toHaveProperty('min');
|
||||
expect(range).toHaveProperty('max');
|
||||
expect(range.min).toBeLessThanOrEqual(range.max);
|
||||
expect(range.min).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
99
packages/api/products/src/lib/products.service.ts
Normal file
99
packages/api/products/src/lib/products.service.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Product, ProductFilter, PaginatedResponse } from '@org/models';
|
||||
|
||||
export class ProductsService {
|
||||
private products: Product[] = this.generateMockProducts();
|
||||
|
||||
private generateMockProducts(): Product[] {
|
||||
const categories = ['Electronics', 'Clothing', 'Books', 'Home & Garden', 'Sports'];
|
||||
const products: Product[] = [];
|
||||
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
const category = categories[Math.floor(Math.random() * categories.length)];
|
||||
products.push({
|
||||
id: `prod-${i}`,
|
||||
name: `Product ${i}`,
|
||||
description: `This is a high-quality ${category.toLowerCase()} product with excellent features and great value for money.`,
|
||||
price: Math.round(Math.random() * 500 * 100) / 100 + 10,
|
||||
category,
|
||||
imageUrl: `https://placehold.co/300x300?text=Product+${i}`,
|
||||
inStock: Math.random() > 0.2,
|
||||
rating: Math.round((Math.random() * 2 + 3) * 10) / 10,
|
||||
reviewCount: Math.floor(Math.random() * 500),
|
||||
});
|
||||
}
|
||||
|
||||
return products;
|
||||
}
|
||||
|
||||
getAllProducts(
|
||||
filter?: ProductFilter,
|
||||
page = 1,
|
||||
pageSize = 12
|
||||
): PaginatedResponse<Product> {
|
||||
let filteredProducts = [...this.products];
|
||||
|
||||
if (filter) {
|
||||
if (filter.category) {
|
||||
filteredProducts = filteredProducts.filter(
|
||||
p => p.category === filter.category
|
||||
);
|
||||
}
|
||||
|
||||
if (filter.minPrice !== undefined) {
|
||||
const minPrice = filter.minPrice;
|
||||
filteredProducts = filteredProducts.filter(p => p.price >= minPrice);
|
||||
}
|
||||
|
||||
if (filter.maxPrice !== undefined) {
|
||||
const maxPrice = filter.maxPrice;
|
||||
filteredProducts = filteredProducts.filter(p => p.price <= maxPrice);
|
||||
}
|
||||
|
||||
if (filter.inStock !== undefined) {
|
||||
filteredProducts = filteredProducts.filter(
|
||||
p => p.inStock === filter.inStock
|
||||
);
|
||||
}
|
||||
|
||||
if (filter.searchTerm) {
|
||||
const searchLower = filter.searchTerm.toLowerCase();
|
||||
filteredProducts = filteredProducts.filter(
|
||||
p =>
|
||||
p.name.toLowerCase().includes(searchLower) ||
|
||||
p.description.toLowerCase().includes(searchLower) ||
|
||||
p.category.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const total = filteredProducts.length;
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const items = filteredProducts.slice(startIndex, endIndex);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
getProductById(id: string): Product | null {
|
||||
return this.products.find(p => p.id === id) || null;
|
||||
}
|
||||
|
||||
getCategories(): string[] {
|
||||
return [...new Set(this.products.map(p => p.category))];
|
||||
}
|
||||
|
||||
getPriceRange(): { min: number; max: number } {
|
||||
const prices = this.products.map(p => p.price);
|
||||
return {
|
||||
min: Math.min(...prices),
|
||||
max: Math.max(...prices),
|
||||
};
|
||||
}
|
||||
}
|
||||
16
packages/api/products/tsconfig.json
Normal file
16
packages/api/products/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs"
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
packages/api/products/tsconfig.lib.json
Normal file
10
packages/api/products/tsconfig.lib.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
28
packages/api/products/tsconfig.spec.json
Normal file
28
packages/api/products/tsconfig.spec.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
]
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
22
packages/api/products/vite.config.ts
Normal file
22
packages/api/products/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite';
|
||||
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/api/products',
|
||||
plugins: [tsconfigPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
test: {
|
||||
name: 'products',
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
reporters: ['default'],
|
||||
passWithNoTests: true,
|
||||
coverage: {
|
||||
reportsDirectory: '../../../coverage/packages/api/products',
|
||||
provider: 'v8' as const,
|
||||
},
|
||||
},
|
||||
}));
|
||||
7
packages/shared/models/README.md
Normal file
7
packages/shared/models/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# models
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test models` to execute the unit tests via [Vitest](https://vitest.dev/).
|
||||
3
packages/shared/models/eslint.config.mjs
Normal file
3
packages/shared/models/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
8
packages/shared/models/project.json
Normal file
8
packages/shared/models/project.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "models",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "packages/shared/models/src",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:shared", "type:data"],
|
||||
"targets": {}
|
||||
}
|
||||
1
packages/shared/models/src/index.ts
Normal file
1
packages/shared/models/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/product.model';
|
||||
34
packages/shared/models/src/lib/product.model.ts
Normal file
34
packages/shared/models/src/lib/product.model.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
price: number;
|
||||
category: string;
|
||||
imageUrl: string;
|
||||
inStock: boolean;
|
||||
rating: number;
|
||||
reviewCount: number;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface ProductFilter {
|
||||
category?: string;
|
||||
minPrice?: number;
|
||||
maxPrice?: number;
|
||||
inStock?: boolean;
|
||||
searchTerm?: string;
|
||||
}
|
||||
23
packages/shared/models/tsconfig.json
Normal file
23
packages/shared/models/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"importHelpers": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
23
packages/shared/models/tsconfig.lib.json
Normal file
23
packages/shared/models/tsconfig.lib.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"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"
|
||||
]
|
||||
}
|
||||
28
packages/shared/models/tsconfig.spec.json
Normal file
28
packages/shared/models/tsconfig.spec.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"types": [
|
||||
"vitest/globals",
|
||||
"vitest/importMeta",
|
||||
"vite/client",
|
||||
"node",
|
||||
"vitest"
|
||||
]
|
||||
},
|
||||
"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"
|
||||
]
|
||||
}
|
||||
26
packages/shared/models/vite.config.ts
Normal file
26
packages/shared/models/vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vite';
|
||||
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/shared/models',
|
||||
plugins: [tsconfigPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
test: {
|
||||
name: 'models',
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
reporters: ['default'],
|
||||
passWithNoTests: true,
|
||||
coverage: {
|
||||
reportsDirectory: '../../../coverage/packages/shared/models',
|
||||
provider: 'v8' as const,
|
||||
},
|
||||
},
|
||||
}));
|
||||
7
packages/shop/data/README.md
Normal file
7
packages/shop/data/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# data
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test data` to execute the unit tests.
|
||||
34
packages/shop/data/eslint.config.mjs
Normal file
34
packages/shop/data/eslint.config.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import nx from '@nx/eslint-plugin';
|
||||
import baseConfig from '../../../eslint.config.mjs';
|
||||
|
||||
export default [
|
||||
...baseConfig,
|
||||
...nx.configs['flat/angular'],
|
||||
...nx.configs['flat/angular-template'],
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
rules: {
|
||||
'@angular-eslint/directive-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'attribute',
|
||||
prefix: 'lib',
|
||||
style: 'camelCase',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/component-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'element',
|
||||
prefix: 'lib',
|
||||
style: 'kebab-case',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.html'],
|
||||
// Override or add rules here
|
||||
rules: {},
|
||||
},
|
||||
];
|
||||
9
packages/shop/data/project.json
Normal file
9
packages/shop/data/project.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "data",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "packages/shop/data/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:shop", "type:data"],
|
||||
"targets": {}
|
||||
}
|
||||
1
packages/shop/data/src/index.ts
Normal file
1
packages/shop/data/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/services/products.service';
|
||||
278
packages/shop/data/src/lib/services/products.service.spec.ts
Normal file
278
packages/shop/data/src/lib/services/products.service.spec.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting, HttpTestingController } from '@angular/common/http/testing';
|
||||
import { ProductsService } from './products.service';
|
||||
import { Product, ApiResponse, PaginatedResponse, ProductFilter } from '@org/models';
|
||||
|
||||
describe('ProductsService', () => {
|
||||
let service: ProductsService;
|
||||
let httpMock: HttpTestingController;
|
||||
const apiUrl = 'http://localhost:3333/api';
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
ProductsService
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(ProductsService);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('getProducts', () => {
|
||||
const mockProductsResponse: ApiResponse<PaginatedResponse<Product>> = {
|
||||
success: true,
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Product 1',
|
||||
description: 'Description 1',
|
||||
price: 100,
|
||||
imageUrl: 'image1.jpg',
|
||||
category: 'Electronics',
|
||||
inStock: true,
|
||||
rating: 4.5,
|
||||
reviewCount: 10,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Product 2',
|
||||
description: 'Description 2',
|
||||
price: 200,
|
||||
imageUrl: 'image2.jpg',
|
||||
category: 'Clothing',
|
||||
inStock: false,
|
||||
rating: 3.5,
|
||||
reviewCount: 5,
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
totalPages: 1,
|
||||
},
|
||||
};
|
||||
|
||||
it('should return products with default pagination', () => {
|
||||
service.getProducts().subscribe((response) => {
|
||||
expect(response.items.length).toBe(2);
|
||||
expect(response.total).toBe(2);
|
||||
expect(response.page).toBe(1);
|
||||
expect(service.loading()).toBeFalsy();
|
||||
expect(service.error()).toBeNull();
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(`${apiUrl}/products?page=1&pageSize=12`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(mockProductsResponse);
|
||||
});
|
||||
|
||||
it('should apply filters when provided', () => {
|
||||
const filter: ProductFilter = {
|
||||
category: 'Electronics',
|
||||
minPrice: 50,
|
||||
maxPrice: 150,
|
||||
inStock: true,
|
||||
searchTerm: 'test',
|
||||
};
|
||||
|
||||
service.getProducts(filter, 2, 20).subscribe((response) => {
|
||||
expect(response).toBeTruthy();
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(
|
||||
`${apiUrl}/products?page=2&pageSize=20&category=Electronics&minPrice=50&maxPrice=150&inStock=true&searchTerm=test`
|
||||
);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(mockProductsResponse);
|
||||
});
|
||||
|
||||
it('should handle error response', () => {
|
||||
const errorResponse: ApiResponse<PaginatedResponse<Product>> = {
|
||||
success: false,
|
||||
error: 'Server error',
|
||||
data: undefined as unknown
|
||||
};
|
||||
|
||||
// Silence console.error for this test
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
service.getProducts().subscribe((response) => {
|
||||
expect(response.items).toEqual([]);
|
||||
expect(response.total).toBe(0);
|
||||
expect(service.error()).toContain('Server error');
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(`${apiUrl}/products?page=1&pageSize=12`);
|
||||
req.flush(errorResponse);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should handle network error', () => {
|
||||
// Silence console.error for this test
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
service.getProducts().subscribe((response) => {
|
||||
expect(response.items).toEqual([]);
|
||||
expect(response.total).toBe(0);
|
||||
expect(service.error()).toBeTruthy();
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(`${apiUrl}/products?page=1&pageSize=12`);
|
||||
req.error(new ProgressEvent('Network error'));
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProductById', () => {
|
||||
const mockProduct: Product = {
|
||||
id: '1',
|
||||
name: 'Product 1',
|
||||
description: 'Description 1',
|
||||
price: 100,
|
||||
imageUrl: 'image1.jpg',
|
||||
category: 'Electronics',
|
||||
inStock: true,
|
||||
rating: 4.5,
|
||||
reviewCount: 10,
|
||||
};
|
||||
|
||||
it('should return a product by id', () => {
|
||||
const mockResponse: ApiResponse<Product> = {
|
||||
success: true,
|
||||
data: mockProduct,
|
||||
};
|
||||
|
||||
service.getProductById('1').subscribe((product) => {
|
||||
expect(product).toEqual(mockProduct);
|
||||
expect(service.loading()).toBeFalsy();
|
||||
expect(service.error()).toBeNull();
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(`${apiUrl}/products/1`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(mockResponse);
|
||||
});
|
||||
|
||||
it('should return null on error', () => {
|
||||
// Silence console.error for this test
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
service.getProductById('1').subscribe((product) => {
|
||||
expect(product).toBeNull();
|
||||
expect(service.error()).toBeTruthy();
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(`${apiUrl}/products/1`);
|
||||
req.error(new ProgressEvent('Network error'));
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCategories', () => {
|
||||
it('should return categories list', () => {
|
||||
const mockCategories = ['Electronics', 'Clothing', 'Books'];
|
||||
const mockResponse: ApiResponse<string[]> = {
|
||||
success: true,
|
||||
data: mockCategories,
|
||||
};
|
||||
|
||||
service.getCategories().subscribe((categories) => {
|
||||
expect(categories).toEqual(mockCategories);
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(`${apiUrl}/products-metadata/categories`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(mockResponse);
|
||||
});
|
||||
|
||||
it('should return empty array on error', () => {
|
||||
// Silence console.error for this test
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
service.getCategories().subscribe((categories) => {
|
||||
expect(categories).toEqual([]);
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(`${apiUrl}/products-metadata/categories`);
|
||||
req.error(new ProgressEvent('Network error'));
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPriceRange', () => {
|
||||
it('should return price range', () => {
|
||||
const mockPriceRange = { min: 10, max: 500 };
|
||||
const mockResponse: ApiResponse<{ min: number; max: number }> = {
|
||||
success: true,
|
||||
data: mockPriceRange,
|
||||
};
|
||||
|
||||
service.getPriceRange().subscribe((range) => {
|
||||
expect(range).toEqual(mockPriceRange);
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(`${apiUrl}/products-metadata/price-range`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(mockResponse);
|
||||
});
|
||||
|
||||
it('should return default range on error', () => {
|
||||
// Silence console.error for this test
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
|
||||
service.getPriceRange().subscribe((range) => {
|
||||
expect(range).toEqual({ min: 0, max: 1000 });
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(`${apiUrl}/products-metadata/price-range`);
|
||||
req.error(new ProgressEvent('Network error'));
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading and error signals', () => {
|
||||
it('should set loading to true when fetching products', () => {
|
||||
expect(service.loading()).toBeFalsy();
|
||||
|
||||
service.getProducts().subscribe();
|
||||
expect(service.loading()).toBeTruthy();
|
||||
|
||||
const req = httpMock.expectOne(`${apiUrl}/products?page=1&pageSize=12`);
|
||||
req.flush({ success: true, data: { items: [], total: 0, page: 1, pageSize: 12, totalPages: 0 } });
|
||||
|
||||
expect(service.loading()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should set error message on failure', () => {
|
||||
// Silence console.error for this test
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
expect(service.error()).toBeNull();
|
||||
|
||||
service.getProductById('1').subscribe(() => {
|
||||
expect(service.error()).toBeTruthy();
|
||||
});
|
||||
|
||||
const req = httpMock.expectOne(`${apiUrl}/products/1`);
|
||||
req.error(new ProgressEvent('Network error'));
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
139
packages/shop/data/src/lib/services/products.service.ts
Normal file
139
packages/shop/data/src/lib/services/products.service.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable, map, catchError, of } from 'rxjs';
|
||||
import { Product, ApiResponse, PaginatedResponse, ProductFilter } from '@org/models';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ProductsService {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly apiUrl = 'http://localhost:3333/api';
|
||||
|
||||
// Signals for state management
|
||||
private readonly loadingSignal = signal(false);
|
||||
private readonly errorSignal = signal<string | null>(null);
|
||||
|
||||
readonly loading = this.loadingSignal.asReadonly();
|
||||
readonly error = this.errorSignal.asReadonly();
|
||||
|
||||
getProducts(
|
||||
filter?: ProductFilter,
|
||||
page = 1,
|
||||
pageSize = 12
|
||||
): Observable<PaginatedResponse<Product>> {
|
||||
this.loadingSignal.set(true);
|
||||
this.errorSignal.set(null);
|
||||
|
||||
let params = new HttpParams()
|
||||
.set('page', page.toString())
|
||||
.set('pageSize', pageSize.toString());
|
||||
|
||||
if (filter) {
|
||||
if (filter.category) {
|
||||
params = params.set('category', filter.category);
|
||||
}
|
||||
if (filter.minPrice !== undefined) {
|
||||
params = params.set('minPrice', filter.minPrice.toString());
|
||||
}
|
||||
if (filter.maxPrice !== undefined) {
|
||||
params = params.set('maxPrice', filter.maxPrice.toString());
|
||||
}
|
||||
if (filter.inStock !== undefined) {
|
||||
params = params.set('inStock', filter.inStock.toString());
|
||||
}
|
||||
if (filter.searchTerm) {
|
||||
params = params.set('searchTerm', filter.searchTerm);
|
||||
}
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<ApiResponse<PaginatedResponse<Product>>>(`${this.apiUrl}/products`, {
|
||||
params,
|
||||
})
|
||||
.pipe(
|
||||
map((response) => {
|
||||
this.loadingSignal.set(false);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to load products');
|
||||
}
|
||||
return response.data;
|
||||
}),
|
||||
catchError((error) => {
|
||||
this.loadingSignal.set(false);
|
||||
this.errorSignal.set(
|
||||
error.message || 'An error occurred while loading products'
|
||||
);
|
||||
console.error('Error loading products:', error);
|
||||
return of({
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: 12,
|
||||
totalPages: 0,
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getProductById(id: string): Observable<Product | null> {
|
||||
this.loadingSignal.set(true);
|
||||
this.errorSignal.set(null);
|
||||
|
||||
return this.http
|
||||
.get<ApiResponse<Product>>(`${this.apiUrl}/products/${id}`)
|
||||
.pipe(
|
||||
map((response) => {
|
||||
this.loadingSignal.set(false);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to load product');
|
||||
}
|
||||
return response.data;
|
||||
}),
|
||||
catchError((error) => {
|
||||
this.loadingSignal.set(false);
|
||||
this.errorSignal.set(
|
||||
error.message || 'An error occurred while loading the product'
|
||||
);
|
||||
console.error('Error loading product:', error);
|
||||
return of(null);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getCategories(): Observable<string[]> {
|
||||
return this.http
|
||||
.get<ApiResponse<string[]>>(`${this.apiUrl}/products-metadata/categories`)
|
||||
.pipe(
|
||||
map((response) => {
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to load categories');
|
||||
}
|
||||
return response.data;
|
||||
}),
|
||||
catchError((error) => {
|
||||
console.error('Error loading categories:', error);
|
||||
return of([]);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
getPriceRange(): Observable<{ min: number; max: number }> {
|
||||
return this.http
|
||||
.get<ApiResponse<{ min: number; max: number }>>(
|
||||
`${this.apiUrl}/products-metadata/price-range`
|
||||
)
|
||||
.pipe(
|
||||
map((response) => {
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || 'Failed to load price range');
|
||||
}
|
||||
return response.data;
|
||||
}),
|
||||
catchError((error) => {
|
||||
console.error('Error loading price range:', error);
|
||||
return of({ min: 0, max: 1000 });
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
13
packages/shop/data/src/test-setup.ts
Normal file
13
packages/shop/data/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()
|
||||
);
|
||||
31
packages/shop/data/tsconfig.json
Normal file
31
packages/shop/data/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "preserve",
|
||||
"lib": ["dom", "es2022"]
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"typeCheckHostBindings": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
packages/shop/data/tsconfig.lib.json
Normal file
27
packages/shop/data/tsconfig.lib.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/test-setup.ts",
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx"
|
||||
],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
30
packages/shop/data/tsconfig.spec.json
Normal file
30
packages/shop/data/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/data/vite.config.mts
Normal file
28
packages/shop/data/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/data',
|
||||
plugins: [angular(), tsconfigPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
test: {
|
||||
name: 'data',
|
||||
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/data',
|
||||
provider: 'v8' as const,
|
||||
},
|
||||
},
|
||||
}));
|
||||
9
packages/shop/feature-product-detail/project.json
Normal file
9
packages/shop/feature-product-detail/project.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "feature-product-detail",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "library",
|
||||
"sourceRoot": "packages/shop/feature-product-detail/src",
|
||||
"prefix": "shop",
|
||||
"tags": ["scope:shop", "type:feature"],
|
||||
"targets": {}
|
||||
}
|
||||
2
packages/shop/feature-product-detail/src/index.ts
Normal file
2
packages/shop/feature-product-detail/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './lib/product-detail/product-detail.component';
|
||||
export * from './lib/feature-product-detail.routes';
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Routes } from '@angular/router';
|
||||
import { ProductDetailComponent } from './product-detail/product-detail.component';
|
||||
|
||||
export const featureProductDetailRoutes: Routes = [
|
||||
{
|
||||
path: ':id',
|
||||
component: ProductDetailComponent,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,113 @@
|
||||
import { TestBed, ComponentFixture } from '@angular/core/testing';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { ProductDetailComponent } from './product-detail.component';
|
||||
import { ProductsService } from '@org/shop/data';
|
||||
import { Product } from '@org/models';
|
||||
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||
|
||||
describe('ProductDetailComponent', () => {
|
||||
let component: ProductDetailComponent;
|
||||
let fixture: ComponentFixture<ProductDetailComponent>;
|
||||
let mockProductsService: Partial<ProductsService>;
|
||||
let mockRouter: Partial<Router>;
|
||||
let mockActivatedRoute: Partial<ActivatedRoute>;
|
||||
|
||||
const mockProduct: Product = {
|
||||
id: '1',
|
||||
name: 'Test Product',
|
||||
description: 'Test Description',
|
||||
price: 99.99,
|
||||
imageUrl: 'https://example.com/image.jpg',
|
||||
category: 'Electronics',
|
||||
inStock: true,
|
||||
rating: 4.5,
|
||||
reviewCount: 100,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockProductsService = {
|
||||
getProductById: vi.fn(),
|
||||
};
|
||||
|
||||
mockRouter = {
|
||||
navigate: vi.fn(),
|
||||
};
|
||||
|
||||
mockActivatedRoute = {
|
||||
snapshot: {
|
||||
paramMap: {
|
||||
get: vi.fn().mockReturnValue('1'),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProductDetailComponent],
|
||||
providers: [
|
||||
{ provide: ProductsService, useValue: mockProductsService },
|
||||
{ provide: Router, useValue: mockRouter },
|
||||
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProductDetailComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load product on init', () => {
|
||||
mockProductsService.getProductById.mockReturnValue(of(mockProduct));
|
||||
|
||||
component.ngOnInit();
|
||||
|
||||
expect(mockProductsService.getProductById).toHaveBeenCalledWith('1');
|
||||
expect(component.product()).toEqual(mockProduct);
|
||||
expect(component.loading()).toBe(false);
|
||||
expect(component.error()).toBe(null);
|
||||
});
|
||||
|
||||
it('should handle error when product not found', () => {
|
||||
mockProductsService.getProductById.mockReturnValue(of(null));
|
||||
|
||||
component.ngOnInit();
|
||||
|
||||
expect(component.error()).toBe('Product not found');
|
||||
expect(component.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle error when loading fails', () => {
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
mockProductsService.getProductById.mockReturnValue(
|
||||
throwError(() => new Error('Network error'))
|
||||
);
|
||||
|
||||
component.ngOnInit();
|
||||
|
||||
expect(component.error()).toBe('Failed to load product details');
|
||||
expect(component.loading()).toBe(false);
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should calculate star ratings correctly', () => {
|
||||
component.product.set(mockProduct);
|
||||
|
||||
const stars = component.getStars();
|
||||
|
||||
expect(stars).toEqual([true, true, true, true, true]);
|
||||
});
|
||||
|
||||
it('should handle add to cart action', () => {
|
||||
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
|
||||
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
component.product.set(mockProduct);
|
||||
|
||||
component.addToCart();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith('Adding to cart:', '1');
|
||||
expect(alertSpy).toHaveBeenCalledWith('Product added to cart!');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,393 @@
|
||||
import { Component, inject, signal, OnInit, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { CommonModule, CurrencyPipe } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { ProductsService } from '@org/shop/data';
|
||||
import { Product } from '@org/models';
|
||||
import { LoadingSpinnerComponent, ErrorMessageComponent } from '@org/shop/shared-ui';
|
||||
|
||||
@Component({
|
||||
selector: 'shop-product-detail',
|
||||
imports: [
|
||||
CommonModule,
|
||||
CurrencyPipe,
|
||||
RouterLink,
|
||||
LoadingSpinnerComponent,
|
||||
ErrorMessageComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="product-detail-container">
|
||||
@if (loading()) {
|
||||
<shop-loading-spinner />
|
||||
} @else if (error()) {
|
||||
<shop-error-message
|
||||
[title]="'Product Not Found'"
|
||||
[message]="error() || undefined"
|
||||
(retry)="loadProduct()"
|
||||
/>
|
||||
} @else if (product()) {
|
||||
<div class="breadcrumb">
|
||||
<a routerLink="/products">← Back to Products</a>
|
||||
</div>
|
||||
|
||||
<div class="product-detail">
|
||||
<div class="product-image-section">
|
||||
<img
|
||||
[src]="product()!.imageUrl"
|
||||
[alt]="product()!.name"
|
||||
class="product-image"
|
||||
/>
|
||||
@if (!product()!.inStock) {
|
||||
<div class="out-of-stock-badge">Out of Stock</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="product-info-section">
|
||||
<div class="product-category">{{ product()!.category }}</div>
|
||||
<h1 class="product-name">{{ product()!.name }}</h1>
|
||||
|
||||
<div class="product-rating">
|
||||
<span class="stars">
|
||||
@for (star of getStars(); track $index) {
|
||||
<span [class.filled]="star">★</span>
|
||||
}
|
||||
</span>
|
||||
<span class="rating-text">
|
||||
{{ product()!.rating }} out of 5
|
||||
</span>
|
||||
<span class="review-count">
|
||||
({{ product()!.reviewCount }} reviews)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="product-price">
|
||||
{{ product()!.price | currency }}
|
||||
</div>
|
||||
|
||||
<div class="product-description">
|
||||
<h2>Description</h2>
|
||||
<p>{{ product()!.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="product-actions">
|
||||
@if (product()!.inStock) {
|
||||
<button class="btn-primary" (click)="addToCart()">
|
||||
Add to Cart
|
||||
</button>
|
||||
<button class="btn-secondary" (click)="addToWishlist()">
|
||||
Add to Wishlist
|
||||
</button>
|
||||
} @else {
|
||||
<button class="btn-disabled" disabled>
|
||||
Currently Unavailable
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="product-info">
|
||||
<h3>Product Information</h3>
|
||||
<dl>
|
||||
<dt>Product ID:</dt>
|
||||
<dd>{{ product()!.id }}</dd>
|
||||
<dt>Category:</dt>
|
||||
<dd>{{ product()!.category }}</dd>
|
||||
<dt>Availability:</dt>
|
||||
<dd>{{ product()!.inStock ? 'In Stock' : 'Out of Stock' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.product-detail-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover {
|
||||
color: #2980b9;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.product-detail {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 48px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.product-image-section {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.out-of-stock-badge {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: rgba(220, 53, 69, 0.9);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.product-info-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.product-category {
|
||||
color: #6c757d;
|
||||
font-size: 0.95rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 2rem;
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.product-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stars {
|
||||
color: #ddd;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.stars .filled {
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
.rating-text {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.review-count {
|
||||
color: #666;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.product-description {
|
||||
border-top: 1px solid #e0e0e0;
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.product-description h2 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 12px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.product-description p {
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.product-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
flex: 1;
|
||||
padding: 12px 24px;
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2980b9;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
flex: 1;
|
||||
padding: 12px 24px;
|
||||
background: transparent;
|
||||
color: #3498db;
|
||||
border: 2px solid #3498db;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-disabled {
|
||||
flex: 1;
|
||||
padding: 12px 24px;
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.product-info h3 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.product-info dl {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.product-info dt {
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.product-info dd {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.product-detail {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.product-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProductDetailComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly productsService = inject(ProductsService);
|
||||
|
||||
// State signals
|
||||
readonly product = signal<Product | null>(null);
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
ngOnInit() {
|
||||
this.loadProduct();
|
||||
}
|
||||
|
||||
loadProduct() {
|
||||
const productId = this.route.snapshot.paramMap.get('id');
|
||||
|
||||
if (!productId) {
|
||||
this.error.set('Product ID not provided');
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
this.productsService.getProductById(productId).subscribe({
|
||||
next: (product) => {
|
||||
if (product) {
|
||||
this.product.set(product);
|
||||
} else {
|
||||
this.error.set('Product not found');
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: (err) => {
|
||||
this.error.set('Failed to load product details');
|
||||
this.loading.set(false);
|
||||
console.error('Error loading product:', err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getStars(): boolean[] {
|
||||
const product = this.product();
|
||||
if (!product) return [];
|
||||
|
||||
const rating = product.rating;
|
||||
const fullStars = Math.floor(rating);
|
||||
const hasHalfStar = rating % 1 >= 0.5;
|
||||
|
||||
return Array(5).fill(false).map((_, index) => {
|
||||
if (index < fullStars) return true;
|
||||
if (index === fullStars && hasHalfStar) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
addToCart() {
|
||||
// This would typically call a cart service
|
||||
console.log('Adding to cart:', this.product()?.id);
|
||||
alert('Product added to cart!');
|
||||
}
|
||||
|
||||
addToWishlist() {
|
||||
// This would typically call a wishlist service
|
||||
console.log('Adding to wishlist:', this.product()?.id);
|
||||
alert('Product added to wishlist!');
|
||||
}
|
||||
}
|
||||
13
packages/shop/feature-product-detail/src/test-setup.ts
Normal file
13
packages/shop/feature-product-detail/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-product-detail/tsconfig.json
Normal file
18
packages/shop/feature-product-detail/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-product-detail/tsconfig.lib.json
Normal file
12
packages/shop/feature-product-detail/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-product-detail/tsconfig.spec.json
Normal file
30
packages/shop/feature-product-detail/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-product-detail/vite.config.mts
Normal file
28
packages/shop/feature-product-detail/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-product-detail',
|
||||
plugins: [angular(), tsconfigPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
test: {
|
||||
name: 'feature-product-detail',
|
||||
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-product-detail',
|
||||
provider: 'v8' as const,
|
||||
},
|
||||
},
|
||||
}));
|
||||
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,
|
||||
},
|
||||
},
|
||||
}));
|
||||
7
packages/shop/shared-ui/README.md
Normal file
7
packages/shop/shared-ui/README.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# shared-ui
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test shared-ui` to execute the unit tests.
|
||||
34
packages/shop/shared-ui/eslint.config.mjs
Normal file
34
packages/shop/shared-ui/eslint.config.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import nx from '@nx/eslint-plugin';
|
||||
import baseConfig from '../../../eslint.config.mjs';
|
||||
|
||||
export default [
|
||||
...baseConfig,
|
||||
...nx.configs['flat/angular'],
|
||||
...nx.configs['flat/angular-template'],
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
rules: {
|
||||
'@angular-eslint/directive-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'attribute',
|
||||
prefix: 'shop',
|
||||
style: 'camelCase',
|
||||
},
|
||||
],
|
||||
'@angular-eslint/component-selector': [
|
||||
'error',
|
||||
{
|
||||
type: 'element',
|
||||
prefix: 'shop',
|
||||
style: 'kebab-case',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.html'],
|
||||
// Override or add rules here
|
||||
rules: {},
|
||||
},
|
||||
];
|
||||
9
packages/shop/shared-ui/project.json
Normal file
9
packages/shop/shared-ui/project.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "shared-ui",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "packages/shop/shared-ui/src",
|
||||
"prefix": "lib",
|
||||
"projectType": "library",
|
||||
"tags": ["scope:shop", "type:ui"],
|
||||
"targets": {}
|
||||
}
|
||||
4
packages/shop/shared-ui/src/index.ts
Normal file
4
packages/shop/shared-ui/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './lib/product-card/product-card.component';
|
||||
export * from './lib/product-grid/product-grid.component';
|
||||
export * from './lib/loading-spinner/loading-spinner.component';
|
||||
export * from './lib/error-message/error-message.component';
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Component, input, output, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'shop-error-message',
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="error-container">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<h3>{{ title() || 'Oops! Something went wrong' }}</h3>
|
||||
<p>{{ message() || 'An unexpected error occurred. Please try again later.' }}</p>
|
||||
@if (showRetry()) {
|
||||
<button class="retry-button" (click)="retry.emit()">
|
||||
Try Again
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 24px 0;
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.retry-button:hover {
|
||||
background: #2980b9;
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ErrorMessageComponent {
|
||||
readonly title = input<string>();
|
||||
readonly message = input<string>();
|
||||
readonly showRetry = input(true);
|
||||
readonly retry = output<void>();
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'shop-loading-spinner',
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="spinner-container">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.spinner-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #3498db;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 16px;
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class LoadingSpinnerComponent {}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { TestBed, ComponentFixture } from '@angular/core/testing';
|
||||
import { ProductCardComponent } from './product-card.component';
|
||||
import { Product } from '@org/models';
|
||||
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||
|
||||
describe('ProductCardComponent', () => {
|
||||
let component: ProductCardComponent;
|
||||
let fixture: ComponentFixture<ProductCardComponent>;
|
||||
|
||||
const mockProduct: Product = {
|
||||
id: '1',
|
||||
name: 'Test Product',
|
||||
description: 'Test Description',
|
||||
price: 99.99,
|
||||
imageUrl: 'https://example.com/image.jpg',
|
||||
category: 'Electronics',
|
||||
inStock: true,
|
||||
rating: 4.5,
|
||||
reviewCount: 100,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProductCardComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProductCardComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.componentRef.setInput('product', mockProduct);
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display product information', () => {
|
||||
fixture.detectChanges();
|
||||
const compiled = fixture.nativeElement;
|
||||
|
||||
expect(compiled.querySelector('.product-name').textContent).toContain('Test Product');
|
||||
expect(compiled.querySelector('.product-category').textContent).toContain('Electronics');
|
||||
expect(compiled.querySelector('.product-price').textContent).toContain('99.99');
|
||||
});
|
||||
|
||||
it('should emit productClick event when card is clicked', () => {
|
||||
const clickSpy = vi.fn();
|
||||
component.productClick.subscribe(clickSpy);
|
||||
|
||||
fixture.nativeElement.querySelector('.product-card').click();
|
||||
|
||||
expect(clickSpy).toHaveBeenCalledWith(mockProduct);
|
||||
});
|
||||
|
||||
it('should display out of stock overlay when product is not in stock', () => {
|
||||
const outOfStockProduct = { ...mockProduct, inStock: false };
|
||||
fixture.componentRef.setInput('product', outOfStockProduct);
|
||||
fixture.detectChanges();
|
||||
|
||||
const compiled = fixture.nativeElement;
|
||||
expect(compiled.querySelector('.out-of-stock-overlay')).toBeTruthy();
|
||||
expect(compiled.querySelector('.product-card').classList).toContain('out-of-stock');
|
||||
});
|
||||
|
||||
it('should calculate star ratings correctly', () => {
|
||||
const stars = component.getStars();
|
||||
|
||||
expect(stars).toEqual([true, true, true, true, true]);
|
||||
});
|
||||
|
||||
it('should handle product with low rating', () => {
|
||||
const lowRatedProduct = { ...mockProduct, rating: 2.3 };
|
||||
fixture.componentRef.setInput('product', lowRatedProduct);
|
||||
|
||||
const stars = component.getStars();
|
||||
|
||||
expect(stars).toEqual([true, true, false, false, false]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Component, input, output, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { CommonModule, CurrencyPipe } from '@angular/common';
|
||||
import { Product } from '@org/models';
|
||||
|
||||
@Component({
|
||||
selector: 'shop-product-card',
|
||||
imports: [CommonModule, CurrencyPipe],
|
||||
template: `
|
||||
<div
|
||||
class="product-card"
|
||||
(click)="productClick.emit(product())"
|
||||
(keyup.enter)="productClick.emit(product())"
|
||||
(keyup.space)="productClick.emit(product())"
|
||||
[class.out-of-stock]="!product().inStock"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
[attr.aria-label]="'View details for ' + product().name"
|
||||
>
|
||||
<div class="product-image">
|
||||
<img [src]="product().imageUrl" [alt]="product().name" />
|
||||
@if (!product().inStock) {
|
||||
<div class="out-of-stock-overlay">Out of Stock</div>
|
||||
}
|
||||
</div>
|
||||
<div class="product-info">
|
||||
<h3 class="product-name">{{ product().name }}</h3>
|
||||
<p class="product-category">{{ product().category }}</p>
|
||||
<div class="product-rating">
|
||||
<span class="stars">
|
||||
@for (star of getStars(); track $index) {
|
||||
<span [class.filled]="star">★</span>
|
||||
}
|
||||
</span>
|
||||
<span class="review-count">({{ product().reviewCount }})</span>
|
||||
</div>
|
||||
<div class="product-price">
|
||||
{{ product().price | currency }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.product-card {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background: white;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.product-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.product-card.out-of-stock {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.product-image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-top: 100%;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.product-image img {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.out-of-stock-overlay {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.product-info {
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.product-category {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.product-rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stars {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.stars .filled {
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
.review-count {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.product-price {
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
margin-top: auto;
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProductCardComponent {
|
||||
readonly product = input.required<Product>();
|
||||
readonly productClick = output<Product>();
|
||||
|
||||
getStars(): boolean[] {
|
||||
const rating = this.product().rating;
|
||||
const fullStars = Math.floor(rating);
|
||||
const hasHalfStar = rating % 1 >= 0.5;
|
||||
|
||||
return Array(5).fill(false).map((_, index) => {
|
||||
if (index < fullStars) return true;
|
||||
if (index === fullStars && hasHalfStar) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Component, input, output, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Product } from '@org/models';
|
||||
import { ProductCardComponent } from '../product-card/product-card.component';
|
||||
|
||||
@Component({
|
||||
selector: 'shop-product-grid',
|
||||
imports: [CommonModule, ProductCardComponent],
|
||||
template: `
|
||||
<div class="product-grid">
|
||||
@for (product of products(); track product.id) {
|
||||
<shop-product-card
|
||||
[product]="product"
|
||||
(productClick)="productSelect.emit($event)"
|
||||
/>
|
||||
}
|
||||
@empty {
|
||||
<div class="no-products">
|
||||
<p>No products found</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.product-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 24px;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.no-products {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: #666;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.product-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProductGridComponent {
|
||||
readonly products = input.required<Product[]>();
|
||||
readonly productSelect = output<Product>();
|
||||
}
|
||||
13
packages/shop/shared-ui/src/test-setup.ts
Normal file
13
packages/shop/shared-ui/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()
|
||||
);
|
||||
31
packages/shop/shared-ui/tsconfig.json
Normal file
31
packages/shop/shared-ui/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "preserve",
|
||||
"lib": ["dom", "es2022"]
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"typeCheckHostBindings": true,
|
||||
"strictTemplates": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
packages/shop/shared-ui/tsconfig.lib.json
Normal file
27
packages/shop/shared-ui/tsconfig.lib.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"inlineSources": true,
|
||||
"types": []
|
||||
},
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/test-setup.ts",
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"vite.config.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
"vitest.config.mts",
|
||||
"src/**/*.test.tsx",
|
||||
"src/**/*.spec.tsx",
|
||||
"src/**/*.test.js",
|
||||
"src/**/*.spec.js",
|
||||
"src/**/*.test.jsx",
|
||||
"src/**/*.spec.jsx"
|
||||
],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
30
packages/shop/shared-ui/tsconfig.spec.json
Normal file
30
packages/shop/shared-ui/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/shared-ui/vite.config.mts
Normal file
28
packages/shop/shared-ui/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/shared-ui',
|
||||
plugins: [angular(), tsconfigPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
// Uncomment this if you are using workers.
|
||||
// worker: {
|
||||
// plugins: [ nxViteTsPaths() ],
|
||||
// },
|
||||
test: {
|
||||
name: 'shared-ui',
|
||||
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/shared-ui',
|
||||
provider: 'v8' as const,
|
||||
},
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user