S SmartDocs
Serie: React.js javascript 259 líneas · Actualizado 2026-04-02

Project_TestableComponents.jsx

React.js/lessons/21_testing_best_practices/projects/Project_TestableComponents.jsx

/**
 * Project: 可測試的元件設計 — 測試與最佳實踐
 *
 * 學習重點:
 * - 單一職責的元件設計
 * - 容器/展示元件分離
 * - 使用 data-testid 方便測試定位
 * - 派生狀態(不需要額外 state)
 * - 清晰的 Props 介面
 *
 * 此檔案展示了良好的元件設計模式
 * 使用方式:將此檔案內容複製到你的 App.jsx 中
 *
 * 測試範例在 Project_TestExamples.test.jsx 中
 */

import { useState, useCallback } from 'react';

// ============================================================
// 展示元件(純粹渲染 UI,容易測試)
// ============================================================

const StatusBadge = ({ status }) => {
  const config = {
    active: { bg: '#f0fdf4', color: '#166534', label: '啟用' },
    inactive: { bg: '#fef2f2', color: '#991b1b', label: '停用' },
    pending: { bg: '#fffbeb', color: '#92400e', label: '待審' },
  };
  const c = config[status] || config.pending;

  return (
    <span
      data-testid={`status-${status}`}
      style={{
        padding: '2px 10px', borderRadius: '6px',
        backgroundColor: c.bg, color: c.color,
        fontSize: '0.75rem', fontWeight: '600',
      }}
    >
      {c.label}
    </span>
  );
};

const SearchInput = ({ value, onChange, placeholder = '搜尋...' }) => (
  <input
    data-testid="search-input"
    value={value}
    onChange={(e) => onChange(e.target.value)}
    placeholder={placeholder}
    style={{
      width: '100%', padding: '10px 14px', borderRadius: '10px',
      border: '2px solid #e2e8f0', fontSize: '0.95rem', outline: 'none',
      boxSizing: 'border-box',
    }}
  />
);

const EmptyState = ({ icon = '📋', message = '沒有資料' }) => (
  <div data-testid="empty-state" style={{ textAlign: 'center', padding: '40px', color: '#cbd5e1' }}>
    <p style={{ fontSize: '2rem', margin: '0 0 8px' }}>{icon}</p>
    <p style={{ margin: 0 }}>{message}</p>
  </div>
);

const UserRow = ({ user, onToggle, onDelete }) => (
  <div
    data-testid={`user-row-${user.id}`}
    style={{
      display: 'flex', alignItems: 'center', gap: '12px',
      padding: '12px 16px', backgroundColor: 'white', borderRadius: '10px',
      marginBottom: '8px', border: '1px solid #f1f5f9',
    }}
  >
    <div
      style={{
        width: '40px', height: '40px', borderRadius: '50%',
        backgroundColor: '#eef2ff', display: 'flex',
        alignItems: 'center', justifyContent: 'center',
        fontSize: '1rem', flexShrink: 0,
      }}
    >
      {user.avatar}
    </div>
    <div style={{ flex: 1 }}>
      <p data-testid={`user-name-${user.id}`} style={{ margin: '0 0 2px', fontWeight: '600', color: '#0f172a', fontSize: '0.95rem' }}>
        {user.name}
      </p>
      <p style={{ margin: 0, color: '#94a3b8', fontSize: '0.8rem' }}>{user.email}</p>
    </div>
    <span style={{ color: '#94a3b8', fontSize: '0.8rem' }}>{user.role}</span>
    <StatusBadge status={user.status} />
    <button
      data-testid={`toggle-${user.id}`}
      onClick={() => onToggle(user.id)}
      style={{
        padding: '4px 12px', borderRadius: '6px',
        border: '1px solid #e2e8f0', backgroundColor: 'white',
        cursor: 'pointer', fontSize: '0.75rem', color: '#64748b',
      }}
    >
      切換
    </button>
    <button
      data-testid={`delete-${user.id}`}
      onClick={() => onDelete(user.id)}
      style={{
        padding: '4px 8px', borderRadius: '6px', border: 'none',
        backgroundColor: 'transparent', color: '#e2e8f0',
        cursor: 'pointer', fontSize: '1.1rem',
      }}
    >
      ×
    </button>
  </div>
);

const Stats = ({ total, active, inactive, pending }) => (
  <div data-testid="stats" style={{
    display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '12px', marginBottom: '20px',
  }}>
    {[
      { label: '總計', value: total, color: '#4f46e5' },
      { label: '啟用', value: active, color: '#22c55e' },
      { label: '停用', value: inactive, color: '#ef4444' },
      { label: '待審', value: pending, color: '#f59e0b' },
    ].map((s) => (
      <div key={s.label} style={{
        padding: '16px', borderRadius: '10px', backgroundColor: 'white',
        border: '1px solid #f1f5f9', textAlign: 'center',
      }}>
        <p data-testid={`stat-${s.label}`} style={{ margin: '0 0 2px', fontSize: '1.5rem', fontWeight: 'bold', color: s.color }}>
          {s.value}
        </p>
        <p style={{ margin: 0, color: '#94a3b8', fontSize: '0.8rem' }}>{s.label}</p>
      </div>
    ))}
  </div>
);

// ============================================================
// 容器元件(管理狀態和邏輯)
// ============================================================

function UserManager() {
  const [users, setUsers] = useState([
    { id: 1, name: 'Alice Chen', email: 'alice@example.com', role: 'Admin', status: 'active', avatar: '👩‍💻' },
    { id: 2, name: 'Bob Wang', email: 'bob@example.com', role: 'Editor', status: 'active', avatar: '👨‍💻' },
    { id: 3, name: 'Carol Lin', email: 'carol@example.com', role: 'Viewer', status: 'inactive', avatar: '👩‍🎨' },
    { id: 4, name: 'David Lee', email: 'david@example.com', role: 'Editor', status: 'pending', avatar: '👨‍💼' },
    { id: 5, name: 'Eva Huang', email: 'eva@example.com', role: 'Admin', status: 'active', avatar: '👩‍🔧' },
  ]);
  const [searchQuery, setSearchQuery] = useState('');
  const [filterStatus, setFilterStatus] = useState('all');

  // 派生狀態(不需要額外的 state)
  const filteredUsers = users
    .filter((u) => filterStatus === 'all' || u.status === filterStatus)
    .filter((u) =>
      u.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
      u.email.toLowerCase().includes(searchQuery.toLowerCase())
    );

  const stats = {
    total: users.length,
    active: users.filter((u) => u.status === 'active').length,
    inactive: users.filter((u) => u.status === 'inactive').length,
    pending: users.filter((u) => u.status === 'pending').length,
  };

  const handleToggle = useCallback((id) => {
    setUsers((prev) =>
      prev.map((u) => {
        if (u.id !== id) return u;
        const statusCycle = { active: 'inactive', inactive: 'pending', pending: 'active' };
        return { ...u, status: statusCycle[u.status] };
      })
    );
  }, []);

  const handleDelete = useCallback((id) => {
    setUsers((prev) => prev.filter((u) => u.id !== id));
  }, []);

  return (
    <div style={{
      maxWidth: '700px', margin: '32px auto', padding: '0 16px',
      fontFamily: "'Segoe UI', 'Noto Sans TC', sans-serif",
    }}>
      <h1 style={{ color: '#0f172a', marginBottom: '4px' }}>使用者管理</h1>
      <p style={{ color: '#94a3b8', marginTop: 0, marginBottom: '24px' }}>
        展示良好的元件設計 — 容器/展示分離、派生狀態、data-testid
      </p>

      <Stats {...stats} />

      <div style={{ display: 'flex', gap: '12px', marginBottom: '16px', alignItems: 'center' }}>
        <div style={{ flex: 1 }}>
          <SearchInput
            value={searchQuery}
            onChange={setSearchQuery}
            placeholder="搜尋使用者姓名或 Email..."
          />
        </div>
        <div style={{ display: 'flex', gap: '4px' }}>
          {['all', 'active', 'inactive', 'pending'].map((s) => (
            <button
              key={s}
              data-testid={`filter-${s}`}
              onClick={() => setFilterStatus(s)}
              style={{
                padding: '8px 14px', borderRadius: '8px', border: 'none',
                backgroundColor: filterStatus === s ? '#4f46e5' : '#f1f5f9',
                color: filterStatus === s ? 'white' : '#64748b',
                cursor: 'pointer', fontSize: '0.8rem',
              }}
            >
              {s === 'all' ? '全部' : s === 'active' ? '啟用' : s === 'inactive' ? '停用' : '待審'}
            </button>
          ))}
        </div>
      </div>

      <p style={{ color: '#94a3b8', fontSize: '0.8rem', marginBottom: '12px' }}>
        顯示 {filteredUsers.length} / {users.length} 位使用者
      </p>

      {filteredUsers.length === 0 ? (
        <EmptyState message="沒有符合條件的使用者" icon="🔍" />
      ) : (
        filteredUsers.map((user) => (
          <UserRow
            key={user.id}
            user={user}
            onToggle={handleToggle}
            onDelete={handleDelete}
          />
        ))
      )}

      {/* 設計說明 */}
      <div style={{
        marginTop: '32px', padding: '20px', borderRadius: '12px',
        backgroundColor: '#f8fafc', border: '1px solid #e2e8f0', fontSize: '0.85rem',
      }}>
        <h3 style={{ margin: '0 0 12px', color: '#0f172a' }}>此專案的設計重點</h3>
        <ul style={{ margin: 0, paddingLeft: '20px', color: '#475569', lineHeight: 2 }}>
          <li><strong>容器/展示分離</strong>:UserManager 管理邏輯,UserRow 純渲染</li>
          <li><strong>派生狀態</strong>:filteredUsers 和 stats 不用額外 useState</li>
          <li><strong>data-testid</strong>:方便測試定位特定元素</li>
          <li><strong>單一職責</strong>:StatusBadge、SearchInput、EmptyState 各司其職</li>
          <li><strong>useCallback</strong>:穩定回呼函式,配合可能的 memo 優化</li>
        </ul>
      </div>
    </div>
  );
}

export default UserManager;

Artículos relacionados