Initial commit
This commit is contained in:
649
commands/feature/frontend.md
Normal file
649
commands/feature/frontend.md
Normal file
@@ -0,0 +1,649 @@
|
||||
# Frontend Layer Operation
|
||||
|
||||
Implement frontend layer only: components, state management, API integration, and tests for a feature.
|
||||
|
||||
## Parameters
|
||||
|
||||
**Received**: `$ARGUMENTS` (after removing 'frontend' operation name)
|
||||
|
||||
Expected format: `description:"UI functionality needed" [framework:"react|vue|angular"] [state:"redux|zustand|context"] [tests:"unit|integration|e2e"]`
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Understand Frontend Requirements
|
||||
|
||||
Clarify:
|
||||
- What UI components are needed?
|
||||
- What user interactions are supported?
|
||||
- What state management is required?
|
||||
- What API endpoints to consume?
|
||||
- What responsive/accessibility requirements?
|
||||
|
||||
### 2. Analyze Existing Frontend Structure
|
||||
|
||||
```bash
|
||||
# Find frontend framework
|
||||
cat package.json | grep -E "(react|vue|angular|svelte)"
|
||||
|
||||
# Find component structure
|
||||
find . -path "*/components/*" -o -path "*/src/app/*"
|
||||
|
||||
# Find state management
|
||||
cat package.json | grep -E "(redux|zustand|mobx|pinia|ngrx)"
|
||||
```
|
||||
|
||||
### 3. Implement Components
|
||||
|
||||
#### Component Structure Example (React + TypeScript)
|
||||
|
||||
```typescript
|
||||
// features/products/components/ProductCard.tsx
|
||||
import React from 'react';
|
||||
import { Product } from '../types';
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product;
|
||||
onAddToCart?: (productId: string) => void;
|
||||
onViewDetails?: (productId: string) => void;
|
||||
}
|
||||
|
||||
export const ProductCard: React.FC<ProductCardProps> = ({
|
||||
product,
|
||||
onAddToCart,
|
||||
onViewDetails,
|
||||
}) => {
|
||||
const [imageError, setImageError] = React.useState(false);
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (onAddToCart) {
|
||||
onAddToCart(product.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetails = () => {
|
||||
if (onViewDetails) {
|
||||
onViewDetails(product.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="product-card" role="article" aria-label={`Product: ${product.name}`}>
|
||||
<div className="product-card__image-container">
|
||||
{!imageError && product.images[0] ? (
|
||||
<img
|
||||
src={product.images[0].url}
|
||||
alt={product.images[0].altText || product.name}
|
||||
className="product-card__image"
|
||||
onError={() => setImageError(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="product-card__placeholder">No image</div>
|
||||
)}
|
||||
|
||||
{product.stockQuantity === 0 && (
|
||||
<div className="product-card__badge product-card__badge--out-of-stock">
|
||||
Out of Stock
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="product-card__content">
|
||||
<h3 className="product-card__title">{product.name}</h3>
|
||||
|
||||
{product.description && (
|
||||
<p className="product-card__description">
|
||||
{product.description.slice(0, 100)}
|
||||
{product.description.length > 100 && '...'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="product-card__footer">
|
||||
<div className="product-card__price">
|
||||
{product.currency} {product.price.toFixed(2)}
|
||||
</div>
|
||||
|
||||
<div className="product-card__actions">
|
||||
<button
|
||||
onClick={handleViewDetails}
|
||||
className="button button--secondary"
|
||||
aria-label={`View details for ${product.name}`}
|
||||
>
|
||||
Details
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
disabled={product.stockQuantity === 0}
|
||||
className="button button--primary"
|
||||
aria-label={`Add ${product.name} to cart`}
|
||||
>
|
||||
Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
```typescript
|
||||
// features/products/components/ProductList.tsx
|
||||
import React from 'react';
|
||||
import { ProductCard } from './ProductCard';
|
||||
import { useProducts } from '../hooks/useProducts';
|
||||
import { Pagination } from '@/components/Pagination';
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner';
|
||||
import { ErrorMessage } from '@/components/ErrorMessage';
|
||||
|
||||
interface ProductListProps {
|
||||
categoryId?: string;
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export const ProductList: React.FC<ProductListProps> = ({
|
||||
categoryId,
|
||||
searchQuery,
|
||||
}) => {
|
||||
const {
|
||||
products,
|
||||
isLoading,
|
||||
error,
|
||||
pagination,
|
||||
onPageChange,
|
||||
onAddToCart,
|
||||
} = useProducts({ categoryId, searchQuery });
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorMessage
|
||||
message={error.message}
|
||||
onRetry={() => window.location.reload()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (products.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">No products found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="product-list">
|
||||
<div className="product-list__grid">
|
||||
{products.map((product) => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
product={product}
|
||||
onAddToCart={onAddToCart}
|
||||
onViewDetails={(id) => console.log('View', id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<Pagination
|
||||
currentPage={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 4. Implement State Management
|
||||
|
||||
#### Using Zustand
|
||||
|
||||
```typescript
|
||||
// features/products/store/productStore.ts
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import { productApi } from '../api/productApi';
|
||||
import { Product } from '../types';
|
||||
|
||||
interface ProductState {
|
||||
products: Product[];
|
||||
selectedProduct: Product | null;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
|
||||
fetchProducts: (filters?: any) => Promise<void>;
|
||||
fetchProduct: (id: string) => Promise<void>;
|
||||
createProduct: (data: any) => Promise<void>;
|
||||
updateProduct: (id: string, data: any) => Promise<void>;
|
||||
deleteProduct: (id: string) => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
export const useProductStore = create<ProductState>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
products: [],
|
||||
selectedProduct: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchProducts: async (filters = {}) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await productApi.list(filters);
|
||||
set({ products: response.data, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({ error, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchProduct: async (id: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const product = await productApi.getById(id);
|
||||
set({ selectedProduct: product, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({ error, isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createProduct: async (data) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const product = await productApi.create(data);
|
||||
set((state) => ({
|
||||
products: [...state.products, product],
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({ error, isLoading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateProduct: async (id, data) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const product = await productApi.update(id, data);
|
||||
set((state) => ({
|
||||
products: state.products.map((p) =>
|
||||
p.id === id ? product : p
|
||||
),
|
||||
selectedProduct:
|
||||
state.selectedProduct?.id === id
|
||||
? product
|
||||
: state.selectedProduct,
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({ error, isLoading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteProduct: async (id) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await productApi.delete(id);
|
||||
set((state) => ({
|
||||
products: state.products.filter((p) => p.id !== id),
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
set({ error, isLoading: false });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => set({ error: null }),
|
||||
}),
|
||||
{
|
||||
name: 'product-storage',
|
||||
partialize: (state) => ({ products: state.products }),
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### 5. Implement API Integration
|
||||
|
||||
```typescript
|
||||
// features/products/api/productApi.ts
|
||||
import axios from 'axios';
|
||||
import { Product, ProductFilters, PaginatedResponse } from '../types';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Response interceptor
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Handle unauthorized
|
||||
localStorage.removeItem('accessToken');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
|
||||
export const productApi = {
|
||||
list: async (filters: ProductFilters): Promise<PaginatedResponse<Product>> => {
|
||||
const response = await apiClient.get('/products', { params: filters });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Product> => {
|
||||
const response = await apiClient.get(`/products/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
create: async (data: Partial<Product>): Promise<Product> => {
|
||||
const response = await apiClient.post('/products', data);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
update: async (id: string, data: Partial<Product>): Promise<Product> => {
|
||||
const response = await apiClient.put(`/products/${id}`, data);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
delete: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/products/${id}`);
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 6. Create Custom Hooks
|
||||
|
||||
```typescript
|
||||
// features/products/hooks/useProducts.ts
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { productApi } from '../api/productApi';
|
||||
import { Product, ProductFilters } from '../types';
|
||||
|
||||
interface UseProductsOptions {
|
||||
categoryId?: string;
|
||||
searchQuery?: string;
|
||||
autoFetch?: boolean;
|
||||
}
|
||||
|
||||
export const useProducts = (options: UseProductsOptions = {}) => {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const fetchProducts = useCallback(
|
||||
async (page: number = 1) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const filters: ProductFilters = {
|
||||
page,
|
||||
limit: 20,
|
||||
categoryId: options.categoryId,
|
||||
search: options.searchQuery,
|
||||
};
|
||||
|
||||
const response = await productApi.list(filters);
|
||||
|
||||
setProducts(response.data);
|
||||
setPagination({
|
||||
page: response.meta.page,
|
||||
totalPages: response.meta.totalPages,
|
||||
total: response.meta.total,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[options.categoryId, options.searchQuery]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (options.autoFetch !== false) {
|
||||
fetchProducts();
|
||||
}
|
||||
}, [fetchProducts, options.autoFetch]);
|
||||
|
||||
const onPageChange = useCallback(
|
||||
(page: number) => {
|
||||
fetchProducts(page);
|
||||
},
|
||||
[fetchProducts]
|
||||
);
|
||||
|
||||
const onAddToCart = useCallback((productId: string) => {
|
||||
// Implement add to cart logic
|
||||
console.log('Add to cart:', productId);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
products,
|
||||
isLoading,
|
||||
error,
|
||||
pagination,
|
||||
fetchProducts,
|
||||
onPageChange,
|
||||
onAddToCart,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### 7. Write Tests
|
||||
|
||||
```typescript
|
||||
// features/products/components/__tests__/ProductCard.test.tsx
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ProductCard } from '../ProductCard';
|
||||
|
||||
const mockProduct = {
|
||||
id: '1',
|
||||
name: 'Test Product',
|
||||
description: 'Test description',
|
||||
price: 99.99,
|
||||
currency: 'USD',
|
||||
stockQuantity: 10,
|
||||
images: [{ url: 'https://example.com/image.jpg', altText: 'Product image' }],
|
||||
};
|
||||
|
||||
describe('ProductCard', () => {
|
||||
it('should render product information', () => {
|
||||
render(<ProductCard product={mockProduct} />);
|
||||
|
||||
expect(screen.getByText('Test Product')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Test description/)).toBeInTheDocument();
|
||||
expect(screen.getByText('USD 99.99')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onAddToCart when button clicked', () => {
|
||||
const onAddToCart = jest.fn();
|
||||
render(<ProductCard product={mockProduct} onAddToCart={onAddToCart} />);
|
||||
|
||||
const addButton = screen.getByRole('button', { name: /add to cart/i });
|
||||
fireEvent.click(addButton);
|
||||
|
||||
expect(onAddToCart).toHaveBeenCalledWith('1');
|
||||
});
|
||||
|
||||
it('should disable add to cart button when out of stock', () => {
|
||||
const outOfStockProduct = { ...mockProduct, stockQuantity: 0 };
|
||||
render(<ProductCard product={outOfStockProduct} />);
|
||||
|
||||
const addButton = screen.getByRole('button', { name: /add to cart/i });
|
||||
expect(addButton).toBeDisabled();
|
||||
expect(screen.getByText('Out of Stock')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle image load error', () => {
|
||||
render(<ProductCard product={mockProduct} />);
|
||||
|
||||
const image = screen.getByRole('img');
|
||||
fireEvent.error(image);
|
||||
|
||||
expect(screen.getByText('No image')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
```typescript
|
||||
// features/products/hooks/__tests__/useProducts.test.ts
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useProducts } from '../useProducts';
|
||||
import { productApi } from '../../api/productApi';
|
||||
|
||||
jest.mock('../../api/productApi');
|
||||
|
||||
const mockProductApi = productApi as jest.Mocked<typeof productApi>;
|
||||
|
||||
describe('useProducts', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should fetch products on mount', async () => {
|
||||
mockProductApi.list.mockResolvedValue({
|
||||
data: [{ id: '1', name: 'Product 1' }],
|
||||
meta: { page: 1, totalPages: 1, total: 1 },
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useProducts());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.products).toHaveLength(1);
|
||||
expect(mockProductApi.list).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle fetch error', async () => {
|
||||
const error = new Error('Fetch failed');
|
||||
mockProductApi.list.mockRejectedValue(error);
|
||||
|
||||
const { result } = renderHook(() => useProducts());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.error).toEqual(error);
|
||||
});
|
||||
|
||||
it('should refetch on page change', async () => {
|
||||
mockProductApi.list.mockResolvedValue({
|
||||
data: [],
|
||||
meta: { page: 1, totalPages: 2, total: 20 },
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useProducts());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.onPageChange(2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockProductApi.list).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ page: 2 })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
```markdown
|
||||
# Frontend Layer: {Feature Name}
|
||||
|
||||
## Components
|
||||
|
||||
### {ComponentName}
|
||||
- Purpose: {description}
|
||||
- Props: {props_list}
|
||||
- State: {state_description}
|
||||
- Code: {component_code}
|
||||
|
||||
## State Management
|
||||
|
||||
### Store/Context
|
||||
\`\`\`typescript
|
||||
{state_management_code}
|
||||
\`\`\`
|
||||
|
||||
## API Integration
|
||||
|
||||
### API Client
|
||||
\`\`\`typescript
|
||||
{api_client_code}
|
||||
\`\`\`
|
||||
|
||||
## Custom Hooks
|
||||
|
||||
### {HookName}
|
||||
\`\`\`typescript
|
||||
{hook_code}
|
||||
\`\`\`
|
||||
|
||||
## Testing
|
||||
|
||||
### Component Tests
|
||||
- {test_description}: {status}
|
||||
|
||||
### Hook Tests
|
||||
- {test_description}: {status}
|
||||
|
||||
## Accessibility
|
||||
- {a11y_considerations}
|
||||
|
||||
## Performance
|
||||
- {performance_optimizations}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If framework unclear: Detect from package.json or ask
|
||||
- If state management unclear: Suggest options
|
||||
- Provide examples for detected framework
|
||||
Reference in New Issue
Block a user