Modern Frontend Engineering
Chapitre 17
Chapitre 17 — Projet Fil Rouge : Enterprise ProjectHub
> Application SaaS complète de gestion de projet — du développement au déploiement.
Projet Fil Rouge : Enterprise ProjectHub — Cours Complet
1. Architecture Globale
1.1 Structure du Monorepo
enterprise-projecthub/
├── apps/
│ ├── web/ # Application principale (React + Vite)
│ │ ├── src/
│ │ │ ├── app/ # Router, providers, layouts
│ │ │ ├── pages/ # Pages (Login, Dashboard, Kanban, etc.)
│ │ │ ├── modules/ # Modules métier
│ │ │ ├── hooks/ # Hooks partagés
│ │ │ └── lib/ # Configuration (i18n, monitoring)
│ │ ├── public/
│ │ ├── index.html
│ │ ├── vite.config.ts
│ │ └── tsconfig.json
│ │
│ └── server/ # BFF (Backend For Frontend)
│ ├── src/
│ │ ├── routes/ # API REST
│ │ ├── graphql/ # Schema + resolvers GraphQL
│ │ ├── middleware/ # Auth, CSP, logging
│ │ └── services/ # Logique métier
│ └── package.json
│
├── packages/
│ ├── ui/ # Design System
│ │ ├── src/
│ │ │ ├── components/ # Button, Input, Modal, Table...
│ │ │ ├── hooks/ # useMediaQuery, useClickOutside
│ │ │ └── tokens/ # Couleurs, spacing, typographie
│ │ └── .storybook/
│ │
│ ├── shared/ # Types, utils, API client
│ │ ├── src/
│ │ │ ├── types/ # User, Project, Task, etc.
│ │ │ ├── api/ # Client REST + GraphQL
│ │ │ ├── utils/ # Dates, formats, validations
│ │ │ └── i18n/ # Config i18n
│ │ └── tests/
│ │
│ ├── auth/ # Module d'authentification
│ │ └── src/
│ │ ├── store.ts # Zustand store
│ │ ├── guards.ts # ProtectedRoute, RequirePermission
│ │ └── hooks.ts # useAuth, useLogin, useLogout
│ │
│ └── features/ # Feature flags
│ └── src/
│ ├── flags.ts
│ └── FeatureFlag.tsx
│
├── tools/
│ ├── generators/ # Générateurs Nx custom
│ └── scripts/ # Scripts CI (a11y, i18n check)
│
├── docker/
│ ├── Dockerfile.web
│ ├── Dockerfile.server
│ └── docker-compose.yml
│
├── .github/
│ └── workflows/
│ ├── ci.yml
│ └── deploy.yml
│
├── nx.json
├── package.json
├── tsconfig.base.json
└── README.md
1.2 Architecture des données
┌─────────────────────────────────────────────────────┐
│ Front-End │
│ ┌──────────┐ ┌───────────┐ ┌──────────────────┐ │
│ │ REST API │ │ GraphQL │ │ WebSocket │ │
│ │ Client │ │ Apollo │ │ Connection │ │
│ └────┬─────┘ └─────┬─────┘ └───────┬──────────┘ │
│ │ │ │ │
└───────┼──────────────┼────────────────┼──────────────┘
│ │ │
┌───────┼──────────────┼────────────────┼──────────────┐
│ ▼ ▼ ▼ │
│ ┌────────────────────────────────────────────┐ │
│ │ BFF (Backend For Frontend) │ │
│ │ ┌─────────┐ ┌──────────┐ ┌─────────────┐ │ │
│ │ │ REST │ │ GraphQL │ │ WebSocket │ │ │
│ │ │ Routes │ │ Resolver │ │ Gateway │ │ │
│ │ └────┬────┘ └────┬─────┘ └──────┬──────┘ │ │
│ │ │ │ │ │ │
│ └───────┼───────────┼──────────────┼──────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌────────────────────────────────────────────┐ │
│ │ Microservices │ │
│ │ ┌──────────┐ ┌──────────┐ ┌────────────┐ │ │
│ │ │ Auth │ │ Project │ │ Notification│ │ │
│ │ │ Service │ │ Service │ │ Service │ │ │
│ │ └────┬─────┘ └────┬─────┘ └──────┬─────┘ │ │
│ │ │ │ │ │ │
│ └───────┼────────────┼───────────────┼────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────┐ ┌──────────┐ ┌────────────┐ │
│ │ Redis │ │PostgreSQL│ │ S3/Minio │ │
│ │(Session)│ │(Primary) │ │(Documents) │ │
│ └─────────┘ └──────────┘ └────────────┘ │
└─────────────────────────────────────────────────────┘
2. Setup du Projet
2.1 Création du workspace Nx
# Créer le workspace
npx create-nx-workspace@latest enterprise-projecthub \
--preset=ts \
--pm=pnpm
cd enterprise-projecthub
# Ajouter les plugins
pnpm add -D @nx/react @nx/vite @nx/storybook @nx/cypress
# Créer l'app web
nx g @nx/react:app web --bundler=vite --unit-test-runner=vitest
# Créer les packages
nx g @nx/js:lib shared
nx g @nx/react:lib ui --bundler=vite --unit-test-runner=vitest
nx g @nx/js:lib auth
nx g @nx/js:lib features
2.2 Configuration TypeScript
// tsconfig.base.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"paths": {
"@projecthub/ui": ["packages/ui/src/index.ts"],
"@projecthub/shared": ["packages/shared/src/index.ts"],
"@projecthub/auth": ["packages/auth/src/index.ts"],
"@projecthub/features": ["packages/features/src/index.ts"]
}
},
"exclude": ["node_modules", "tmp", "dist"]
}
2.3 Configuration Vite
// apps/web/vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [react(), tsconfigPaths()],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'],
ui: ['@projecthub/ui'],
graphql: ['@apollo/client'],
d3: ['d3'],
},
},
},
},
server: {
proxy: {
'/api': 'http://localhost:4000',
'/graphql': 'http://localhost:4000/graphql',
'/ws': {
target: 'ws://localhost:4000',
ws: true,
},
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
},
});
3. Authentification
3.1 Architecture Auth
┌──────────┐ ┌──────────┐ ┌─────────────┐
│ Client │────►│ BFF │────►│ Auth Service│
│ React │ │ Express │ │ (JWT) │
└──────────┘ └──────────┘ └─────────────┘
│ │ │
│ POST /api/auth/login │ Vérifier identifiants
│◄─── JWT (access + refresh)────────│
│ │
│ Stocker refreshToken dans cookie │
│ httpOnly, secure, sameSite=strict │
│ │
│ AccessToken en mémoire (Zustand) │
│ + refresh automatique │
3.2 Implémentation
// packages/auth/src/store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AuthState {
user: User | null;
accessToken: string | null;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
refreshToken: () => Promise<boolean>;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
user: null,
accessToken: null,
isAuthenticated: false,
login: async (email, password) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include', // Envoie les cookies
});
if (!response.ok) throw new Error('Invalid credentials');
const { user, accessToken } = await response.json();
set({ user, accessToken, isAuthenticated: true });
},
logout: async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
set({ user: null, accessToken: null, isAuthenticated: false });
},
refreshToken: async () => {
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
credentials: 'include',
});
if (!response.ok) return false;
const { accessToken } = await response.json();
set({ accessToken });
return true;
} catch {
set({ user: null, accessToken: null, isAuthenticated: false });
return false;
}
},
}),
{
name: 'auth-storage',
partialize: (state) => ({ user: state.user }),
}
)
);
3.3 RBAC Implementation
// packages/auth/src/guards.tsx
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from './store';
type Role = 'admin' | 'manager' | 'member';
const ROLE_HIERARCHY: Record<Role, number> = {
admin: 3,
manager: 2,
member: 1,
};
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore(s => s.isAuthenticated);
const location = useLocation();
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
}
export function RequireRole({ role, children }: { role: Role; children: React.ReactNode }) {
const user = useAuthStore(s => s.user);
if (!user || ROLE_HIERARCHY[user.role] < ROLE_HIERARCHY[role]) {
return <Navigate to="/unauthorized" replace />;
}
return <>{children}</>;
}
4. Dashboard
4.1 KPIs et Graphiques D3
// apps/web/src/modules/dashboard/Dashboard.tsx
import { useEffect, useState } from 'react';
import { useWebSocket } from '../../hooks/useWebSocket';
import { KPICard } from './KPICard';
import { ProjectChart } from './ProjectChart';
import { ActivityFeed } from './ActivityFeed';
import { TeamVelocity } from './TeamVelocity';
interface DashboardData {
kpis: {
activeProjects: number;
tasksCompleted: number;
teamMembers: number;
completionRate: number;
};
projectActivity: { date: string; projects: number }[];
teamVelocity: { sprint: string; completed: number; planned: number }[];
recentActivity: Activity[];
}
export function Dashboard() {
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
// Données initiales via GraphQL
useEffect(() => {
fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `
query GetDashboard {
kpis { activeProjects tasksCompleted teamMembers completionRate }
projectActivity { date projects }
teamVelocity { sprint completed planned }
recentActivity { id user action target timestamp }
}
`,
}),
})
.then(r => r.json())
.then(({ data }) => {
setData(data);
setLoading(false);
});
}, []);
// Mise à jour temps réel via WebSocket
useWebSocket('dashboard:update', (update: Partial<DashboardData>) => {
setData(prev => prev ? { ...prev, ...update } : prev);
});
if (loading) return <DashboardSkeleton />;
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* KPIs */}
<KPICard title="Projets actifs" value={data.kpis.activeProjects} icon="folder" />
<KPICard title="Tâches complétées" value={data.kpis.tasksCompleted} icon="check" trend="+12%" />
<KPICard title="Membres" value={data.kpis.teamMembers} icon="users" />
<KPICard title="Taux de complétion" value={`${data.kpis.completionRate}%`} icon="pie-chart" />
{/* Graphiques */}
<div className="col-span-2">
<ProjectChart data={data.projectActivity} />
</div>
<div className="col-span-2">
<TeamVelocity data={data.teamVelocity} />
</div>
{/* Activity Feed */}
<div className="col-span-full">
<ActivityFeed activities={data.recentActivity} />
</div>
</div>
);
}
4.2 Graphique D3
// ProjectChart.tsx
import { useRef, useEffect } from 'react';
import * as d3 from 'd3';
interface Props {
data: { date: string; projects: number }[];
}
export function ProjectChart({ data }: Props) {
const svgRef = useRef<SVGSVGElement>(null);
useEffect(() => {
if (!svgRef.current || !data.length) return;
const margin = { top: 20, right: 30, bottom: 30, left: 40 };
const width = svgRef.current.clientWidth - margin.left - margin.right;
const height = 300 - margin.top - margin.bottom;
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
const g = svg
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
const x = d3
.scaleBand()
.domain(data.map(d => d.date))
.range([0, width])
.padding(0.1);
const y = d3
.scaleLinear()
.domain([0, d3.max(data, d => d.projects) || 0])
.range([height, 0]);
// Barres
g.selectAll('rect')
.data(data)
.join('rect')
.attr('x', d => x(d.date) || 0)
.attr('y', d => y(d.projects))
.attr('width', x.bandwidth())
.attr('height', d => height - y(d.projects))
.attr('fill', '#3b82f6')
.attr('rx', 4)
.on('mouseenter', function () {
d3.select(this).attr('fill', '#2563eb');
})
.on('mouseleave', function () {
d3.select(this).attr('fill', '#3b82f6');
});
// Axes
g.append('g')
.call(d3.axisLeft(y))
.attr('color', '#94a3b8');
g.append('g')
.attr('transform', `translate(0,${height})`)
.call(d3.axisBottom(x))
.attr('color', '#94a3b8')
.selectAll('text')
.attr('transform', 'rotate(-45)')
.style('text-anchor', 'end');
}, [data]);
return <svg ref={svgRef} className="w-full h-[300px]" />;
}
5. Module Kanban
5.1 Architecture
// apps/web/src/modules/kanban/types.ts
export type TaskStatus = 'todo' | 'in_progress' | 'review' | 'done';
export interface Task {
id: string;
title: string;
description: string;
status: TaskStatus;
priority: 'low' | 'medium' | 'high' | 'critical';
assignee: { id: string; name: string; avatar: string };
dueDate: string;
tags: string[];
order: number;
}
export const COLUMNS: { id: TaskStatus; title: string }[] = [
{ id: 'todo', title: 'À faire' },
{ id: 'in_progress', title: 'En cours' },
{ id: 'review', title: 'En revue' },
{ id: 'done', title: 'Terminé' },
];
5.2 Kanban Board avec dnd-kit
// KanbanBoard.tsx
import { useState } from 'react';
import {
DndContext,
DragOverlay,
closestCorners,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from '@dnd-kit/core';
import {
SortableContext,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { Column } from './Column';
import { TaskCard } from './TaskCard';
import type { Task, TaskStatus } from './types';
export function KanbanBoard() {
const [tasks, setTasks] = useState<Task[]>([]);
const [activeTask, setActiveTask] = useState<Task | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor)
);
const getTasksByStatus = (status: TaskStatus) =>
tasks.filter(t => t.status === status).sort((a, b) => a.order - b.order);
const handleDragStart = (event: DragStartEvent) => {
setActiveTask(tasks.find(t => t.id === event.active.id) || null);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
const taskId = active.id as string;
const targetStatus = over.data.current?.status as TaskStatus;
setTasks(prev =>
prev.map(task =>
task.id === taskId
? { ...task, status: targetStatus }
: task
)
);
// Mutation optimiste + API call
updateTaskStatus(taskId, targetStatus);
setActiveTask(null);
};
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="grid grid-cols-4 gap-4 h-full">
{COLUMNS.map(column => (
<Column
key={column.id}
id={column.id}
title={column.title}
tasks={getTasksByStatus(column.id)}
/>
))}
</div>
<DragOverlay>
{activeTask && <TaskCard task={activeTask} isDragOverlay />}
</DragOverlay>
</DndContext>
);
}
5.3 Column Component
// Column.tsx
import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { TaskCard } from './TaskCard';
import type { Task, TaskStatus } from './types';
interface Props {
id: TaskStatus;
title: string;
tasks: Task[];
}
export function Column({ id, title, tasks }: Props) {
const { setNodeRef, isOver } = useDroppable({
id: `column-${id}`,
data: { status: id },
});
return (
<div
ref={setNodeRef}
className={`bg-secondary-50 rounded-lg p-4 transition-colors ${
isOver ? 'bg-secondary-100 ring-2 ring-primary-400' : ''
}`}
>
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-secondary-900">{title}</h3>
<span className="text-sm text-secondary-500 bg-secondary-200 px-2 py-1 rounded-full">
{tasks.length}
</span>
</div>
<SortableContext items={tasks.map(t => t.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-3">
{tasks.map(task => (
<TaskCard key={task.id} task={task} />
))}
</div>
</SortableContext>
</div>
);
}
6. WebSocket Temps Réel
6.1 Hook WebSocket
// apps/web/src/hooks/useWebSocket.ts
import { useEffect, useRef, useCallback } from 'react';
import { useAuthStore } from '@projecthub/auth';
type MessageHandler = (data: any) => void;
const handlers = new Map<string, Set<MessageHandler>>();
let ws: WebSocket | null = null;
let reconnectAttempts = 0;
function connect() {
const token = useAuthStore.getState().accessToken;
if (!token) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${protocol}//${window.location.host}/ws?token=${token}`;
ws = new WebSocket(url);
ws.onopen = () => {
reconnectAttempts = 0;
console.log('[WS] Connected');
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
const eventHandlers = handlers.get(message.type);
if (eventHandlers) {
eventHandlers.forEach(h => h(message.data));
}
};
ws.onclose = () => {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
reconnectAttempts++;
setTimeout(connect, delay);
};
ws.onerror = console.error;
}
export function useWebSocket(event: string, handler: MessageHandler) {
const handlerRef = useRef(handler);
handlerRef.current = handler;
useEffect(() => {
if (!handlers.has(event)) {
handlers.set(event, new Set());
}
handlers.get(event)!.add(handlerRef.current);
// Connecter au premier handler
if (!ws) connect();
return () => {
handlers.get(event)?.delete(handlerRef.current);
if (handlers.get(event)?.size === 0) {
handlers.delete(event);
}
};
}, [event]);
}
export function sendWS(type: string, data: unknown) {
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type, data }));
}
}
6.2 Notifications en Temps Réel
// apps/web/src/modules/notifications/NotificationCenter.tsx
import { useState } from 'react';
import { useWebSocket } from '../../hooks/useWebSocket';
import { Bell } from 'lucide-react';
interface Notification {
id: string;
type: 'info' | 'warning' | 'success' | 'error';
title: string;
message: string;
timestamp: string;
read: boolean;
}
export function NotificationCenter() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [isOpen, setIsOpen] = useState(false);
// Nouvelles notifications en temps réel
useWebSocket('notification:new', (notification: Notification) => {
setNotifications(prev => [notification, ...prev]);
});
const unreadCount = notifications.filter(n => !n.read).length;
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="relative p-2 rounded-lg hover:bg-secondary-100"
>
<Bell className="w-5 h-5" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center">
{unreadCount}
</span>
)}
</button>
{isOpen && (
<div className="absolute right-0 mt-2 w-96 bg-white rounded-lg shadow-xl border border-secondary-200 max-h-96 overflow-y-auto">
<div className="p-4 border-b border-secondary-200">
<h4 className="font-semibold">Notifications</h4>
</div>
{notifications.length === 0 ? (
<p className="p-4 text-center text-secondary-500">Aucune notification</p>
) : (
notifications.map(notification => (
<div
key={notification.id}
className={`p-4 border-b border-secondary-100 hover:bg-secondary-50 cursor-pointer ${
!notification.read ? 'bg-primary-50' : ''
}`}
onClick={() => markAsRead(notification.id)}
>
<div className="flex items-start gap-3">
<NotificationIcon type={notification.type} />
<div>
<p className="font-medium text-sm">{notification.title}</p>
<p className="text-sm text-secondary-600">{notification.message}</p>
<p className="text-xs text-secondary-400 mt-1">
{formatRelativeTime(notification.timestamp)}
</p>
</div>
</div>
</div>
))
)}
</div>
)}
</div>
);
}
7. Design System
7.1 Design Tokens
// packages/ui/src/tokens/colors.ts
export const colors = {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
secondary: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
},
success: { 500: '#22c55e', 600: '#16a34a' },
warning: { 500: '#f59e0b', 600: '#d97706' },
danger: { 500: '#ef4444', 600: '#dc2626' },
} as const;
export const spacing = {
xs: '0.25rem',
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
xl: '2rem',
'2xl': '3rem',
} as const;
export const typography = {
fontFamily: "'Inter', system-ui, sans-serif",
fontSize: {
xs: '0.75rem',
sm: '0.875rem',
base: '1rem',
lg: '1.125rem',
xl: '1.25rem',
'2xl': '1.5rem',
'3xl': '1.875rem',
},
fontWeight: {
normal: 400,
medium: 500,
semibold: 600,
bold: 700,
},
} as const;
7.2 Tailwind Config
// packages/ui/tailwind.config.ts
import type { Config } from 'tailwindcss';
import { colors, spacing, typography } from './src/tokens';
export default {
content: ['./src/**/*.{ts,tsx}'],
darkMode: 'class',
theme: {
extend: {
colors,
spacing,
fontFamily: {
sans: [typography.fontFamily],
},
fontSize: typography.fontSize,
fontWeight: typography.fontWeight,
animation: {
'fade-in': 'fadeIn 0.2s ease-out',
'slide-in': 'slideIn 0.3s ease-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideIn: {
'0%': { transform: 'translateY(-10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
},
},
plugins: [
require('@tailwindcss/forms'),
require('@tailwindcss/typography'),
],
} satisfies Config;
8. Tests
8.1 Tests Unitaires (Vitest)
// packages/ui/src/Button/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';
describe('Button', () => {
it('renders with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('calls onClick when clicked', () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Click</Button>);
fireEvent.click(screen.getByText('Click'));
expect(onClick).toHaveBeenCalledOnce();
});
it('does not call onClick when disabled', () => {
const onClick = vi.fn();
render(<Button onClick={onClick} disabled>Click</Button>);
fireEvent.click(screen.getByText('Click'));
expect(onClick).not.toHaveBeenCalled();
});
it('shows loading state', () => {
render(<Button loading>Loading</Button>);
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.getByText('Loading')).toBeInTheDocument();
});
it('applies variant classes', () => {
const { rerender } = render(<Button variant="primary">Primary</Button>);
expect(screen.getByText('Primary')).toHaveClass('bg-primary-600');
rerender(<Button variant="danger">Danger</Button>);
expect(screen.getByText('Danger')).toHaveClass('bg-red-600');
});
});
8.2 Tests E2E (Playwright)
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Authentication', () => {
test('should login successfully', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'admin@projecthub.com');
await page.fill('[data-testid="password"]', 'password123');
await page.click('[data-testid="login-button"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('[data-testid="user-name"]')).toHaveText('Admin User');
});
test('should show error on invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.fill('[data-testid="email"]', 'wrong@email.com');
await page.fill('[data-testid="password"]', 'wrong');
await page.click('[data-testid="login-button"]');
await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
await expect(page.locator('[data-testid="error-message"]')).toHaveText(/Invalid/);
});
test('should redirect unauthenticated user', async ({ page }) => {
await page.goto('/dashboard');
await expect(page).toHaveURL('/login');
});
});
8.3 Storybook Tests
// packages/ui/src/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
import { expect, userEvent, within } from '@storybook/test';
const meta: Meta<typeof Button> = {
title: 'UI/Button',
component: Button,
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof Button>;
export const ClickInteraction: Story = {
args: { children: 'Click me' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button');
await userEvent.click(button);
await expect(button).toHaveFocus();
},
};
export const AccessibilityTest: Story = {
args: { children: 'Submit', 'aria-label': 'Submit form' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const button = canvas.getByRole('button', { name: 'Submit form' });
await expect(button).toBeInTheDocument();
await expect(button).not.toBeDisabled();
},
};
9. CI/CD
9.1 GitHub Actions
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Lint
run: npx nx affected --target=lint
- name: Type check
run: npx nx affected --target=typecheck
- name: Unit tests
run: npx nx affected --target=test --coverage
- name: Build
run: npx nx affected --target=build
- name: E2E tests
run: npx nx affected --target=e2e
- name: Upload coverage
uses: codecov/codecov-action@v3
deploy-preview:
if: github.event_name == 'pull_request'
needs: quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pnpm install
- run: npx nx build web --prod
- name: Deploy to Vercel Preview
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
deploy-production:
if: github.ref == 'refs/heads/main'
needs: quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pnpm install
- run: npx nx build web --prod
- name: Build Docker image
run: |
docker build -t projecthub/web:latest -f docker/Dockerfile.web .
docker tag projecthub/web:latest projecthub/web:${{ github.sha }}
- name: Deploy
run: |
echo "Deploy to production cluster..."
9.2 Dockerfile
# docker/Dockerfile.web
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
COPY nx.json tsconfig.base.json ./
COPY packages ./packages
COPY apps/web ./apps/web
RUN pnpm install
RUN npx nx build web --prod
FROM nginx:alpine
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY --from=builder /app/dist/apps/web /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
10. Monitoring et Documentation
10.1 Sentry Configuration
// apps/web/src/lib/monitoring.ts
import * as Sentry from '@sentry/react';
import { BrowserTracing } from '@sentry/tracing';
import { useEffect } from 'react';
import {
createRoutesFromChildren,
matchRoutes,
useLocation,
useNavigationType,
} from 'react-router-dom';
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.MODE,
integrations: [
new BrowserTracing({
routingInstrumentation: Sentry.reactRouterV6Instrumentation(
useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes
),
}),
new Sentry.Replay(),
],
tracesSampleRate: 0.2,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
});
10.2 ADRs
Les ADRs sont stockés dans docs/adr/ à la racine du projet.
docs/adr/
├── ADR-001-monorepo-nx.md
├── ADR-002-auth-jwt.md
├── ADR-003-api-rest-graphql.md
├── ADR-004-design-system.md
├── ADR-005-testing-strategy.md
└── ADR-006-deployment-strategy.md
Voir adr/README.md pour un exemple complet.
10.3 Guide de Contribution
# Guide de Contribution
## Convention de commits
- `feat:` Nouvelle fonctionnalité
- `fix:` Correction de bug
- `chore:` Tâche technique
- `docs:` Documentation
- `test:` Tests
- `refactor:` Réusinage
- `style:` Style/Formatting
## Workflow
1. Créer une branche depuis `develop` : `feat/nom-fonctionnalite`
2. Développer avec des commits atomiques
3. Ouvrir une Pull Request vers `develop`
4. Attendre les checks CI (lint, test, build)
5. Code review par 2 personnes minimum
6. Merge squash si approuvé
## Structure d'un commit
feat(kanban): ajouter le drag & drop entre colonnes
- Intégration de @dnd-kit/core et @dnd-kit/sortable
- Gestion des états de drag (idle, dragging, over)
- Mutation optimiste avec rollback
- Tests unitaires et E2E
## Standards de code
- TypeScript strict mode
- Composants fonctionnels avec hooks
- Tests pour toute nouvelle fonctionnalité
- Accessibilité WCAG 2.2 AA
- Storybook pour tous les composants UI
11. Conclusion
Enterprise ProjectHub est l'aboutissement de tous les concepts vus dans la formation :
- Architecture monorepo avec Nx
- Design System avec tokens et Storybook
- Authentification JWT + OAuth2 + RBAC
- Data fetching REST + GraphQL avec Apollo
- Temps réel WebSocket pour notifications et dashboard
- Tests Vitest (unitaires) + Playwright (E2E) + Storybook (interaction)
- CI/CD GitHub Actions + Docker
- Monitoring Sentry + Lighthouse CI
- Documentation ADRs + Storybook + diagrammes
Le projet est conçu pour évoluer : micro-frontends, WASM pour le traitement d'images, edge computing, et bien plus.