Files
gh-dhofheinz-open-plugins-p…/commands/feature/frontend.md
2025-11-29 18:20:21 +08:00

16 KiB

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

# 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)

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

// 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

// 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

// 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

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

# 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