web: refactor in order to get an actual interface
- Splits the app in pages, components and services. - Handles the routing. - Creates an API service. - Uses Pico CSS. - Adds tests. Co-Authored-by: iGor milhit <igor@milhit.ch>
parent
c728ca57ef
commit
453a6c95ee
|
|
@ -0,0 +1,2 @@
|
|||
# web/.env.development
|
||||
VITE_API_URL=http://localhost:8000
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# API URL (required)
|
||||
VITE_API_URL=http://localhost:8000
|
||||
|
|
@ -24,3 +24,13 @@ dist-ssr
|
|||
*.sw?
|
||||
|
||||
.vite
|
||||
|
||||
.env
|
||||
.env.local
|
||||
.env*.local
|
||||
!.env.example
|
||||
!.env.template
|
||||
!env.development
|
||||
!env.production
|
||||
|
||||
coverage
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -8,7 +8,9 @@
|
|||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest"
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.0",
|
||||
|
|
@ -16,16 +18,22 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@picocss/pico": "^2.1.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.1",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"@vitest/ui": "^4.0.16",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"sass": "^1.97.2",
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
|
|
|
|||
260
web/src/App.jsx
260
web/src/App.jsx
|
|
@ -1,229 +1,39 @@
|
|||
import { useState } from "react";
|
||||
import "./App.css";
|
||||
// web/src/App.jsx
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
||||
import Layout from './components/Layout'
|
||||
import Home from './pages/Home'
|
||||
import About from "./pages/About";
|
||||
import Calculator from './pages/Calculator'
|
||||
import NotFound from './pages/NotFound'
|
||||
|
||||
// Define the routes
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: <Layout />,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <Home />,
|
||||
},
|
||||
{
|
||||
path: 'about',
|
||||
element: <About />,
|
||||
},
|
||||
{
|
||||
path: 'calculator',
|
||||
element: <Calculator />,
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <NotFound />,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
function App() {
|
||||
const [formData, setFormData] = useState({
|
||||
total_flour: "",
|
||||
hydration: "",
|
||||
sourdough_percentage: "",
|
||||
sourdough_flour_parts: "1",
|
||||
sourdough_water_parts: "1",
|
||||
salt: "",
|
||||
});
|
||||
|
||||
const [result, setResult] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Transform form data to API format
|
||||
const apiData = {
|
||||
total_flour: parseFloat(formData.total_flour),
|
||||
hydration: parseFloat(formData.hydration),
|
||||
sourdough_percentage: parseFloat(formData.sourdough_percentage),
|
||||
sourdough_ratio: {
|
||||
flour_parts: parseFloat(formData.sourdough_flour_parts),
|
||||
water_parts: parseFloat(formData.sourdough_water_parts),
|
||||
},
|
||||
salt: parseFloat(formData.salt),
|
||||
};
|
||||
|
||||
const response = await fetch("http://localhost:8000/calculate", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(apiData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(
|
||||
errorData.detail || `HTTP error! status: ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setResult(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<h1>Bread Dough Calculator</h1>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="total_flour">Total Flour (g):</label>
|
||||
<input
|
||||
type="number"
|
||||
id="total_flour"
|
||||
name="total_flour"
|
||||
value={formData.total_flour}
|
||||
onChange={handleChange}
|
||||
min="1"
|
||||
step="1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="hydration">Hydration (%):</label>
|
||||
<input
|
||||
type="number"
|
||||
id="hydration"
|
||||
name="hydration"
|
||||
value={formData.hydration}
|
||||
onChange={handleChange}
|
||||
min="50"
|
||||
max="100"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="sourdough_percentage">Sourdough (% of flour):</label>
|
||||
<input
|
||||
type="number"
|
||||
id="sourdough_percentage"
|
||||
name="sourdough_percentage"
|
||||
value={formData.sourdough_percentage}
|
||||
onChange={handleChange}
|
||||
min="0"
|
||||
max="50"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<fieldset className="ratio-fieldset">
|
||||
<legend>Sourdough Ratio (Flour:Water)</legend>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="sourdough_flour_parts">Flour parts:</label>
|
||||
<input
|
||||
type="number"
|
||||
id="sourdough_flour_parts"
|
||||
name="sourdough_flour_parts"
|
||||
value={formData.sourdough_flour_parts}
|
||||
onChange={handleChange}
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="sourdough_water_parts">Water parts:</label>
|
||||
<input
|
||||
type="number"
|
||||
id="sourdough_water_parts"
|
||||
name="sourdough_water_parts"
|
||||
value={formData.sourdough_water_parts}
|
||||
onChange={handleChange}
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="salt">Salt (% of flour):</label>
|
||||
<input
|
||||
type="number"
|
||||
id="salt"
|
||||
name="salt"
|
||||
value={formData.salt}
|
||||
onChange={handleChange}
|
||||
min="0"
|
||||
max="5"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={loading}>
|
||||
{loading ? "Calculating..." : "Calculate"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<div className="error">
|
||||
<h2>Error</h2>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="results">
|
||||
<h2>Ingredients to Add</h2>
|
||||
<div className="result-grid">
|
||||
<div className="result-item">
|
||||
<span className="label">Flour:</span>
|
||||
<span className="value">{result.ingredients_to_add.flour}g</span>
|
||||
</div>
|
||||
<div className="result-item">
|
||||
<span className="label">Water:</span>
|
||||
<span className="value">{result.ingredients_to_add.water}g</span>
|
||||
</div>
|
||||
<div className="result-item">
|
||||
<span className="label">Sourdough:</span>
|
||||
<span className="value">
|
||||
{result.ingredients_to_add.sourdough}g
|
||||
</span>
|
||||
</div>
|
||||
<div className="result-item">
|
||||
<span className="label">Salt:</span>
|
||||
<span className="value">{result.ingredients_to_add.salt}g</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Recipe Details</h3>
|
||||
<div className="result-grid">
|
||||
<div className="result-item">
|
||||
<span className="label">Total flour:</span>
|
||||
<span className="value">{result.details.total_flour}g</span>
|
||||
</div>
|
||||
<div className="result-item">
|
||||
<span className="label">Total water:</span>
|
||||
<span className="value">{result.details.total_water}g</span>
|
||||
</div>
|
||||
<div className="result-item">
|
||||
<span className="label">Flour in sourdough:</span>
|
||||
<span className="value">
|
||||
{result.details.flour_in_sourdough}g
|
||||
</span>
|
||||
</div>
|
||||
<div className="result-item">
|
||||
<span className="label">Water in sourdough:</span>
|
||||
<span className="value">
|
||||
{result.details.water_in_sourdough}g
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App
|
||||
|
|
|
|||
|
|
@ -1,12 +0,0 @@
|
|||
// web/src/App.test.jsx
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import App from './App'
|
||||
|
||||
describe('App', () => {
|
||||
it('affiche le formulaire', () => {
|
||||
render(<App />)
|
||||
expect(screen.getByLabelText(/farine/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /calculer/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
// web/src/components/BreadForm.jsx
|
||||
|
||||
function BreadForm({ onSubmit, loading }) {
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<fieldset>
|
||||
<legend>Paramètres de base</legend>
|
||||
|
||||
<label htmlFor="total_flour">
|
||||
Farine totale (g)
|
||||
<input
|
||||
type="number"
|
||||
id="total_flour"
|
||||
name="total_flour"
|
||||
defaultValue="500"
|
||||
min="1"
|
||||
step="1"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label htmlFor="hydration">
|
||||
Hydratation (%)
|
||||
<input
|
||||
type="number"
|
||||
id="hydration"
|
||||
name="hydration"
|
||||
defaultValue="60"
|
||||
min="50"
|
||||
max="100"
|
||||
step="1"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label htmlFor="salt">
|
||||
Sel (% de la farine)
|
||||
<input
|
||||
type="number"
|
||||
id="salt"
|
||||
name="salt"
|
||||
defaultValue="1.6"
|
||||
min="0"
|
||||
max="5"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Levain</legend>
|
||||
|
||||
<label htmlFor="sourdough_percentage">
|
||||
Levain (% de la farine)
|
||||
<input
|
||||
type="number"
|
||||
id="sourdough_percentage"
|
||||
name="sourdough_percentage"
|
||||
defaultValue="20"
|
||||
min="0"
|
||||
max="120"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid">
|
||||
<label htmlFor="sourdough_flour_parts">
|
||||
Ratio farine
|
||||
<input
|
||||
type="number"
|
||||
id="sourdough_flour_parts"
|
||||
name="sourdough_flour_parts"
|
||||
defaultValue="1"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label htmlFor="sourdough_water_parts">
|
||||
Ratio eau
|
||||
<input
|
||||
type="number"
|
||||
id="sourdough_water_parts"
|
||||
name="sourdough_water_parts"
|
||||
defaultValue="1"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<button type="submit" disabled={loading} aria-busy={loading}>
|
||||
{loading ? "Calcul en cours..." : "Calculer"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default BreadForm;
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
// web/src/components/BreadResult.jsx
|
||||
|
||||
function BreadResult({ result }) {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<article>
|
||||
<header>
|
||||
<strong>✅ Ingrédients à ajouter</strong>
|
||||
</header>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">Farine</th>
|
||||
<td>{result.ingredients_to_add.flour} g</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Eau</th>
|
||||
<td>{result.ingredients_to_add.water} g</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Levain</th>
|
||||
<td>{result.ingredients_to_add.sourdough} g</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Sel</th>
|
||||
<td>{result.ingredients_to_add.salt} g</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<details>
|
||||
<summary>Détails de la recette</summary>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row">Farine totale</th>
|
||||
<td>{result.details.total_flour} g</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Eau totale</th>
|
||||
<td>{result.details.total_water} g</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Farine dans le levain</th>
|
||||
<td>{result.details.flour_in_sourdough} g</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Eau dans le levain</th>
|
||||
<td>{result.details.water_in_sourdough} g</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
</article>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BreadResult;
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
// web/src/components/ErrorMessage.jsx
|
||||
|
||||
function ErrorMessage({ error }) {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<article style={{ backgroundColor: 'var(--pico-del-color)' }}>
|
||||
<header>
|
||||
<strong>❌ Erreur</strong>
|
||||
</header>
|
||||
<p>{error}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default ErrorMessage;
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
// web/src/components/Layout.jsx
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import Navigation from './Navigation'
|
||||
|
||||
function Layout() {
|
||||
return (
|
||||
<>
|
||||
<Navigation />
|
||||
<Outlet />
|
||||
|
||||
<footer className="container">
|
||||
<hr />
|
||||
<small>
|
||||
Fait avec ❤️ pour les amateurs et le amatrices de pain au levain •
|
||||
<a href="https://git.milhit.ch/igor/dough-calc"
|
||||
target="_blank" rel="noopener noreferrer"
|
||||
title="Code source sur ma forge git"
|
||||
>
|
||||
Source
|
||||
</a>
|
||||
</small>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Layout;
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
// web/src/components/Navigation.jsx
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
function Navigation() {
|
||||
return (
|
||||
<nav className="container">
|
||||
<ul>
|
||||
<li>
|
||||
<Link to="/">
|
||||
<strong>🥖 Calculette à pâton</strong>
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>
|
||||
<Link to="/">🏠 Accueil</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/about">À propos</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/calculator">🖩 Calculette</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default Navigation;
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
// web/src/components/__tests__/BreadForm.test.jsx
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import BreadForm from '../BreadForm'
|
||||
|
||||
describe('BreadForm', () => {
|
||||
it('should render all form fields', () => {
|
||||
render(<BreadForm onSubmit={vi.fn()} loading={false} />)
|
||||
|
||||
expect(screen.getByLabelText(/farine totale/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/hydratation/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/sel/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/levain.*%/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/ratio farine/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/ratio eau/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have default values', () => {
|
||||
render(<BreadForm onSubmit={vi.fn()} loading={false} />)
|
||||
|
||||
expect(screen.getByLabelText(/farine totale/i)).toHaveValue(500)
|
||||
expect(screen.getByLabelText(/hydratation/i)).toHaveValue(60)
|
||||
expect(screen.getByLabelText(/sel/i)).toHaveValue(1.6)
|
||||
expect(screen.getByLabelText(/levain.*%/i)).toHaveValue(20)
|
||||
})
|
||||
|
||||
it('should call onSubmit when form is submitted', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleSubmit = vi.fn((e) => e.preventDefault())
|
||||
|
||||
render(<BreadForm onSubmit={handleSubmit} loading={false} />)
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /calculer/i })
|
||||
await user.click(submitButton)
|
||||
|
||||
expect(handleSubmit).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should disable submit button when loading', () => {
|
||||
render(<BreadForm onSubmit={vi.fn()} loading={true} />)
|
||||
|
||||
const submitButton = screen.getByRole('button')
|
||||
expect(submitButton).toBeDisabled()
|
||||
expect(submitButton).toHaveTextContent('Calcul en cours...')
|
||||
})
|
||||
|
||||
it('should allow user to change input values', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<BreadForm onSubmit={vi.fn()} loading={false} />)
|
||||
|
||||
const flourInput = screen.getByLabelText(/farine totale/i)
|
||||
await user.clear(flourInput)
|
||||
await user.type(flourInput, '1000')
|
||||
|
||||
expect(flourInput).toHaveValue(1000)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
// web/src/components/__tests__/BreadResult.test.jsx
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import BreadResult from '../BreadResult'
|
||||
|
||||
describe('BreadResult', () => {
|
||||
const mockResult = {
|
||||
ingredients_to_add: {
|
||||
flour: 400,
|
||||
water: 260,
|
||||
sourdough: 100,
|
||||
salt: 10,
|
||||
},
|
||||
details: {
|
||||
total_flour: 500,
|
||||
total_water: 325,
|
||||
flour_in_sourdough: 50,
|
||||
water_in_sourdough: 50,
|
||||
},
|
||||
}
|
||||
|
||||
it('should not render when result is null', () => {
|
||||
render(<BreadResult result={null} />)
|
||||
|
||||
const article = screen.queryByRole('article')
|
||||
expect(article).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render ingredients to add', () => {
|
||||
render(<BreadResult result={mockResult} />)
|
||||
|
||||
expect(screen.getByText('✅ Ingrédients à ajouter')).toBeInTheDocument()
|
||||
expect(screen.getByText('400 g')).toBeInTheDocument()
|
||||
expect(screen.getByText('260 g')).toBeInTheDocument()
|
||||
expect(screen.getByText('100 g')).toBeInTheDocument()
|
||||
expect(screen.getByText('10 g')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render details in collapsed state by default', () => {
|
||||
render(<BreadResult result={mockResult} />)
|
||||
|
||||
const details = screen.getByText('Détails de la recette')
|
||||
expect(details).toBeInTheDocument()
|
||||
|
||||
// Les détails sont dans un <details> qui est fermé par défaut
|
||||
const detailsElement = details.closest('details')
|
||||
expect(detailsElement).not.toHaveAttribute('open')
|
||||
})
|
||||
|
||||
it('should display all ingredient labels', () => {
|
||||
render(<BreadResult result={mockResult} />)
|
||||
|
||||
expect(screen.getByText('Farine')).toBeInTheDocument()
|
||||
expect(screen.getByText('Eau')).toBeInTheDocument()
|
||||
expect(screen.getByText('Levain')).toBeInTheDocument()
|
||||
expect(screen.getByText('Sel')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
// web/src/components/__tests__/ErrorMessage.test.jsx
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import ErrorMessage from '../ErrorMessage'
|
||||
|
||||
describe('ErrorMessage', () => {
|
||||
it('should not render when error is null', () => {
|
||||
render(<ErrorMessage error={null} />)
|
||||
|
||||
const article = screen.queryByRole('article')
|
||||
expect(article).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render when error is undefined', () => {
|
||||
render(<ErrorMessage error={undefined} />)
|
||||
|
||||
const article = screen.queryByRole('article')
|
||||
expect(article).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render error message when error is provided', () => {
|
||||
const errorMessage = 'Une erreur est survenue'
|
||||
render(<ErrorMessage error={errorMessage} />)
|
||||
|
||||
expect(screen.getByText('❌ Erreur')).toBeInTheDocument()
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct styling for error', () => {
|
||||
render(<ErrorMessage error="Test error" />)
|
||||
|
||||
const article = screen.getByRole('article')
|
||||
expect(article).toHaveStyle({
|
||||
backgroundColor: 'var(--pico-del-color)'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
// web/src/main.jsx
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './styles/main.scss' // ✅ Import Pico + vos styles
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
// web/src/pages/About.jsx
|
||||
|
||||
function About() {
|
||||
return (
|
||||
<>
|
||||
<main className="container">
|
||||
<header className="container">
|
||||
<h1>À propos</h1>
|
||||
</header>
|
||||
<article>
|
||||
<h2>Histoire</h2>
|
||||
<p>
|
||||
J'avais un petit bout de code Python pour m'aider
|
||||
à faire ces calculs. Je voulais simplement mettre à
|
||||
disposition la logique, très simple pourtant.
|
||||
</p>
|
||||
<p>
|
||||
J'avais besoin de réaliser un petit projet pour évaluer
|
||||
la faisabilité d'utiliser certains outils pour un autre
|
||||
projet, plus conséquent.
|
||||
</p>
|
||||
<p>
|
||||
<strong>
|
||||
⚠️ Tout seul, je ne suis pas capable de développer une
|
||||
application, même aussi simple !
|
||||
</strong> Aussi, j'utilise des modèles de langage proposés par les
|
||||
assistants de <a href="https://kagi.com">Kagi</a>. J'essaie
|
||||
d'apprendre en faisant, mais le code est essentiellement
|
||||
généré par la boîte noire. Mais j'en porte l'entière
|
||||
responsabilité, à défaut du mérite qui revient aux femmes
|
||||
et aux hommes dont le travail a rendu cet outil possible.
|
||||
</p>
|
||||
</article>
|
||||
<article>
|
||||
<h2>
|
||||
Outils
|
||||
</h2>
|
||||
<p>
|
||||
<ul>
|
||||
<li>FastAPI.</li>
|
||||
<li>React et Vite.</li>
|
||||
<li>Pico CSS.</li>
|
||||
</ul>
|
||||
</p>
|
||||
</article>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default About;
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
// web/src/pages/Calculator.jsx
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import BreadForm from "../components/BreadForm";
|
||||
import BreadResult from "../components/BreadResult";
|
||||
import ErrorMessage from "../components/ErrorMessage";
|
||||
import { calculateBread } from "../services/api";
|
||||
|
||||
function Calculator() {
|
||||
const [result, setResult] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
const params = {
|
||||
total_flour: parseFloat(formData.get("total_flour")),
|
||||
hydration: parseFloat(formData.get("hydration")),
|
||||
sourdough_percentage: parseFloat(formData.get("sourdough_percentage")),
|
||||
sourdough_ratio: {
|
||||
flour_parts: parseFloat(formData.get("sourdough_flour_parts")),
|
||||
water_parts: parseFloat(formData.get("sourdough_water_parts")),
|
||||
},
|
||||
salt: parseFloat(formData.get("salt")),
|
||||
};
|
||||
|
||||
try {
|
||||
const data = await calculateBread(params);
|
||||
setResult(data);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="container">
|
||||
<hgroup>
|
||||
<h1>Calculatrice de pain</h1>
|
||||
<p>Calculez les proportions exactes pour votre recette</p>
|
||||
</hgroup>
|
||||
|
||||
<BreadForm onSubmit={handleSubmit} loading={loading} />
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
<BreadResult result={result} />
|
||||
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default Calculator;
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
// web/src/pages/Home.jsx
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
function Home() {
|
||||
return (
|
||||
<>
|
||||
<header className="container">
|
||||
<hgroup>
|
||||
<h1>Une calculette 🖩 pour votre pâton 🥖</h1>
|
||||
<p>
|
||||
Calculez les proportions des ingrédients pour votre
|
||||
pain au levain.
|
||||
</p>
|
||||
</hgroup>
|
||||
</header>
|
||||
|
||||
<main className="container">
|
||||
<section>
|
||||
<h2>Pourquoi utiliser cette calculette ?</h2>
|
||||
<div className="grid">
|
||||
<article>
|
||||
<header>
|
||||
<h3>💦 Pour une hydratation précise.</h3>
|
||||
</header>
|
||||
<p>
|
||||
Tenez-vous compte de la farine et de l'eau
|
||||
contenues dans le levain que vous ajoutez à
|
||||
votre pâton ?
|
||||
</p>
|
||||
<p>
|
||||
La calculette fait les calculs pour vous.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<header>
|
||||
<h3>🧮 Levain personnalisé.</h3>
|
||||
</header>
|
||||
<p>
|
||||
Gérez votre ratio levain
|
||||
<code>farine:eau</code> :
|
||||
<code>1:1</code>, <code>1:0.8</code>,
|
||||
<code>1:1.2</code>…
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<header>
|
||||
<h3>⚡Résultat instantané.</h3>
|
||||
</header>
|
||||
<p>
|
||||
Résultats immédiats avec détails complets :
|
||||
quantités exactes de chaque ingrédient.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Comment ça marche ?</h2>
|
||||
<ol>
|
||||
<li>
|
||||
<strong>Indiquez la quantité de farine totale</strong> désirée pour votre pain.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Choisissez votre taux d'hydratation</strong> (eau en % de la farine totale).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Définissez le ratio <code>farine:eau</code> levain</strong>.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Ajoutez le sel</strong> (par exemple 1.6 % de la farine, ou 16 g / kg).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Obtenez les quantités exactes</strong> à utiliser pour votre recette
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p style={{ textAlign: 'center' }}>
|
||||
<Link to="/calculator" role="button">
|
||||
Commencer le calcul →
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
// web/src/pages/NotFound.jsx
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
function NotFound() {
|
||||
return (
|
||||
<main className="container">
|
||||
<article style={{ textAlign: 'center', padding: '4rem 2rem' }}>
|
||||
<header>
|
||||
<h1>404</h1>
|
||||
<p>Page non trouvée</p>
|
||||
</header>
|
||||
<p>
|
||||
Désolé, la page que vous recherchez n'existe pas.
|
||||
</p>
|
||||
<footer>
|
||||
<Link to="/" role="button">
|
||||
Retour à l'accueil
|
||||
</Link>
|
||||
</footer>
|
||||
</article>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotFound;
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
// web/src/services/__tests__/api.test.js
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { calculateBread, healthCheck } from '../api'
|
||||
|
||||
describe('API Service', () => {
|
||||
beforeEach(() => {
|
||||
// Mock global fetch
|
||||
global.fetch = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('calculateBread', () => {
|
||||
it('should make POST request with correct data', async () => {
|
||||
const mockResponse = {
|
||||
ingredients_to_add: {
|
||||
flour: 400,
|
||||
water: 260,
|
||||
sourdough: 100,
|
||||
salt: 10,
|
||||
},
|
||||
details: {
|
||||
total_flour: 500,
|
||||
total_water: 325,
|
||||
flour_in_sourdough: 50,
|
||||
water_in_sourdough: 50,
|
||||
},
|
||||
}
|
||||
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
})
|
||||
|
||||
const params = {
|
||||
total_flour: 500,
|
||||
hydration: 65,
|
||||
sourdough_percentage: 20,
|
||||
sourdough_ratio: {
|
||||
flour_parts: 1,
|
||||
water_parts: 1,
|
||||
},
|
||||
salt: 2,
|
||||
}
|
||||
|
||||
const result = await calculateBread(params)
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'http://localhost:8000/calculate',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
|
||||
it('should throw error when API returns error', async () => {
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: async () => ({ detail: 'Invalid parameters' }),
|
||||
})
|
||||
|
||||
await expect(
|
||||
calculateBread({ total_flour: -1 })
|
||||
).rejects.toThrow('Invalid parameters')
|
||||
})
|
||||
|
||||
it('should throw network error when fetch fails', async () => {
|
||||
global.fetch.mockRejectedValueOnce(new Error('Failed to fetch'))
|
||||
|
||||
await expect(
|
||||
calculateBread({ total_flour: 500 })
|
||||
).rejects.toThrow(/Impossible de contacter le serveur/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('healthCheck', () => {
|
||||
it('should make GET request to root endpoint', async () => {
|
||||
const mockResponse = { status: 'ok' }
|
||||
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
})
|
||||
|
||||
const result = await healthCheck()
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'http://localhost:8000/',
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
expect(result).toEqual(mockResponse)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
// web/src/services/api.js
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
|
||||
/**
|
||||
* Generic API request handler
|
||||
* @param {string} endpoint - API endpoint (e.g., '/calculate')
|
||||
* @param {object} options - Fetch options
|
||||
* @returns {Promise<object>} - Response data
|
||||
* @throws {Error} - API error with message
|
||||
*/
|
||||
async function apiRequest(endpoint, options = {}) {
|
||||
const url = `${API_URL}${endpoint}`;
|
||||
|
||||
const config = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
// Handle HTTP errors
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
errorData.detail ||
|
||||
errorData.message ||
|
||||
`HTTP error! status: ${response.status}`
|
||||
);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
// Re-throw with more context if it's a network error
|
||||
if (error.message === 'Failed to fetch') {
|
||||
throw new Error(
|
||||
`Impossible de contacter le serveur. Vérifiez que l'API est démarrée sur ${API_URL}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate bread proportions
|
||||
* @param {object} params - Bread calculation parameters
|
||||
* @param {number} params.total_flour - Total flour in grams
|
||||
* @param {number} params.hydration - Hydration percentage
|
||||
* @param {number} params.sourdough_percentage - Sourdough percentage
|
||||
* @param {object} params.sourdough_ratio - Sourdough flour:water ratio
|
||||
* @param {number} params.salt - Salt percentage
|
||||
* @returns {Promise<object>} - Calculation results
|
||||
*/
|
||||
export async function calculateBread(params) {
|
||||
return apiRequest('/calculate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check endpoint
|
||||
* @returns {Promise<object>} - API status
|
||||
*/
|
||||
export async function healthCheck() {
|
||||
return apiRequest('/');
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// web/src/styles/main.scss
|
||||
|
||||
// Import Pico CSS avec les valeurs par défaut
|
||||
@use "@picocss/pico" as *;
|
||||
|
||||
// Vos styles personnalisés (minimes pour commencer)
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
// web/src/test/setup.js
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
// web/vite.config.js
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.js',
|
||||
css: true,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue