MFormations
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.