---
marp: true
---


<style>
pre { font-size: 0.5em !important; }
table { font-size: 0.6em !important; } 
</style>

# React + Redux
## William Petit - S.C.O.P. Cadoles

---

## Amorçage d'un projet

---

<!-- page_number: true -->

## Méthode simplifiée: `create-react-app`


```bash
npm install -g create-react-app # Installation du générateur d'application
create-react-app my-app 	# Génération de l'application
cd my-app			# Se placer dans le répertoire
npm start			# Lancer le serveur de développement
```

https://github.com/facebook/create-react-app

---

## Méthode manuelle

### Installation des dépendances

```bash
mkdir my-app				# Création du répertoire de l'application
cd my-app				# Se placer dans le répertoire
npm init				# Initialiser le projet NPM
					# Installation des dépendances NPM
npm install --save \
	react react-dom react-hot-loader \
	webpack webpack-dev-server \
	webpack-cli \
	babel-core babel-loader  \
	babel-preset-env babel-preset-react \
	babel-preset-stage-2 \
	style-loader file-loader css-loader \
	html-webpack-plugin clean-webpack-plugin \
	uglifyjs-webpack-plugin webpack-merge
    
```

---

### Configuration de Webpack

```js
// my-app/webpack.common.js

const webpack = require('webpack')
const HTMLPlugin = require('html-webpack-plugin')

module.exports = {
  entry: {
    main: './src/index.js'
  },
  module: {
    rules: [
      { test: /\.(js|jsx)$/, exclude: /node_modules/, use: ['babel-loader'] },
      { test: /\.(scss|sass)$/, use: ['style-loader', 'css-loader'] },
      { test: /\.(png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)(\?.*$|$)/, use: ['file-loader'] }
    ]
  },
  resolve: {
    extensions: ['*', '.js', '.jsx']
  },
  plugins: [
    new webpack.ProvidePlugin({
      'React': 'react'
    }),
    new HTMLPlugin({
      title: 'MyApp',
      hash: true,
      template: 'src/index.html'
    })
  ]
}
```

---

```js
// my-app/webpack.dev.js

const webpack = require('webpack')
const common = require('./webpack.common.js')
const merge = require('webpack-merge')
const path = require('path')

module.exports = merge(common, {
  devtool: 'inline-source-map',
  mode: 'development',
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ],
  output: {
    path: path.join(__dirname, 'public'),
    publicPath: '/',
    filename: '[name].[hash].js'
  },
  devServer: {
    contentBase: './public',
    hot: true
  }
})
```

---

```
// my-app/webpack.prod.js

const common = require('./webpack.common.js')
const merge = require('webpack-merge')
const path = require('path')
const webpack = require('webpack')
const CleanWebpackPlugin = require('clean-webpack-plugin')
const UglifyJSPlugin = require('uglifyjs-webpack-plugin')

module.exports = merge(common, {
  mode: 'production',
  output: {
    path: path.join(__dirname, 'dist'),
    publicPath: '/',
    filename: '[name].[chunkhash].js'
  },
  devtool: 'source-map',
  plugins: [
    new CleanWebpackPlugin(['dist']),
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify('production')
    }),
    new UglifyJSPlugin({
      sourceMap: true
    })
  ]
})
```

---

### Compléter le fichier `package.json`

```js
// my-app/package.json

{

  "scripts": {
    "start": "webpack-dev-server --progress --colors --config webpack.dev.js",
    "build": "webpack --progress --colors --config webpack.prod.js"
  },
  
  // ...
  
  "babel": {
    "presets": [
      "env",
      "react",
      "stage-2"
    ],
    "plugins": ["react-hot-loader/babel"]
  }

}
```
---

### Création des fichiers source de base

```html
<!-- my-app/src/index.html -->

<!DOCTYPE html>
<html>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>MyApp</title>
  <% for (var css in htmlWebpackPlugin.files.css) { %>
  <link href="<%= htmlWebpackPlugin.files.css[css] %>" rel="stylesheet">
  <% } %>
  <body>
    <div id="app"></div>
  </body>
</html>
```
https://github.com/jantimon/html-webpack-plugin

---

```js
// my-app/src/index.js

import ReactDOM from 'react-dom'
import App from './app'

ReactDOM.render(<App />, document.getElementById('app'))
```

```js
// my-app/src/app.js

import { Component } from 'react'
import { hot } from 'react-hot-loader'

class App extends Component {
  render () {
    return <h1>Hello World !</h1>
  }
}

export default hot(module)(App)
```

---

### Lancer le serveur de développement

```bash
npm start 	# Puis ouvrir http://localhost:8080 dans votre navigateur
```

### Générer une version pour la production

```bash
npm run build 	# Les fichiers générés seront dans le répertoire my-app/dist
```

---

## Les composants

---

## Différentes notations

### Classe ES6

```jsx
// my-app/src/components/hello-world.js
import React from 'react'

class HelloWorld extends React.Component {

  render() {
    return (
      <div className='hello-world'>
        <h1>Hello World</h1>
        <p>Welcome to React</p>
      </div>
    )
  }

}

export default HelloWorld
```
---

## Notation fonctionnelle

```jsx
// my-app/src/components/hello-world.js

const HelloWorld = () => {
  return (
    <div className='hello-world'>
      <h1>Hello World</h1>
      <p>Welcome to React</p>
    </div>
  )
}

export default HelloWorld
```
---

## La syntaxe JSX

C'est une représentation proche du XML d'une arborescence de composants. Il ne faut pas faire d'amalgame, **ce n'est pas du HTML**.

La définition des attributs propres au DOM se fait via le nom de la propriété Javascript, pas de l'attribut HTML. **Par exemple l'attribut`class` en HTML devient `className` en JSX**.

Il en est de même pour la **définition des styles**.

---

## `props`

Elles permettent de passer des données aux composants.

```jsx
// my-app/src/components/props-example.js

import React from 'react'

class PropsExample extends React.Component {

  render() {
    return (
      <span>{ this.props.text }</span>
    )
  }

}

export default PropsExample

// Exemple d'utilisation

// <PropsExample text="foo bar" />

```
---

### `props.children`

La propriété `children` permet de récupérer les composants 'enfants' qui pourraient être passés au composant lors de son rendu.

```jsx
// my-app/src/components/my-list.js

import React from 'react'

class MyList extends React.Component {

  render() {
    return (
      <ul>
        { this.props.children }
      </ul>
    )
  }

}

export default PropsExample


// Exemple d'utilisation

<MyList>
  <li>Item 1</li>
  <li>Item 2</li>
</MyList>

```

---

## `state`

La propriété `state` d'un composant représente l'état (au sens données) de celui ci.

```jsx
// my-app/src/components/clock.js

import React from 'react'

export default class Clock extends React.Component {

  constructor(props) {
    super(props)
    // Initialisation du "state" du composant
    this.state = {
      time: new Date()
    }
    // On appelle la méthode tick() du composant
    // toutes les secondes
    setInterval(this.tick.bind(this), 1000);
  }
	
  // Méthode de rendu du composant
  render() {
    return (
      <div>Time: { this.state.time.toString() }</div>
    )
  }
  
  // La méthode tick() met à jour le state du composant avec
  // la date courante
  tick() {
    this.setState({ time: new Date() });
  }

}
```
---

## Événements du DOM

Les événements du DOM sont interceptés par le passage de "callbacks" sur les propriétés `on<Event>` des composants.

---

```jsx
// my-app/src/components/counter.js

import React from 'react'

export default class Counter extends React.Component {
  constructor(props) {
    super(props)
    // Initialisation du "state" du composant
    this.state = {
      count: 0
    }
    // On "lie" les méthodes de la classe à l'instance
    this.increment = this.increment.bind(this)
    this.decrement = this.decrement.bind(this)
  }
  // Méthode de rendu du composant
  render() {
    return (
      <div>
      	Count: <span>{ this.state.count }</span>&nbsp;
        <button onClick={ this.increment }>+1</button>
        <button onClick={ this.decrement }>-1</button>
      </div>
    )
  }
  // La méthode increment() incrémente la valeur du compteur de 1
  increment() {
    this.setState(prevState => ({ count: prevState.count+1 }))
  }
  // La méthode decrement() décrémente la valeur du compteur de 1
  decrement() {
    this.setState(prevState => ({ count: prevState.count-1 }))
  }
}
```

---

## Gestion des styles

Différentes méthodologies existent pour gérer les styles des composants.

La méthode classique CSS (avec ou sans préprocesseur SCSS/SASS) est tout à fait viable.

Les styles des composants sont également souvent déclarés en Javascript et utilisés via les `props` des composants.

D'autres solutions plus avancées comme [styled-components](https://www.styled-components.com/) ou [CSS Modules](https://github.com/css-modules/css-modules) existent également. Elles mettent l'accent sur la modularité et la réutilisation des règles de style entre les composants.

---

### Déclaration des styles en Javascript

```jsx
// my-app/src/components/my-styled-component.styles.js

export default {
  self: {
    backgroundColor: 'red',
    border: '1px solid black',
    padding: '10px'
  },
  content: {
    backgroundColor: 'blue',
    border: '1px dotted white'
  }
}
```

```jsx
// my-app/src/components/my-styled-component.js

import React from 'react'
import styles from './my-styled-component.styles.js'

export default class MyStyledComponent extends React.Component {

  render() {
    return (
      <div style={styles.self}>
      	<div style={styles.content}>
          Isnt it pretty ?
        </div>
      </div>
    )
  }

}
```
---
## Composition des styles en Javascript

En utilisant l'opérateur de décomposition `...`, il est possible
de composer les styles.

```jsx
// my-app/src/components/my-styled-component.js

import React from 'react'
import styles from './my-styled-component.styles.js'

export default class MyStyledComponent extends React.Component {

  render() {
    return (
      <div style={{...styles.self, ...this.props.style}}>
      	<div style={{...styles.content, ...this.props.contentStyle}}>
          Isnt it pretty ?
        </div>
      </div>
    )
  }

}

// Exemple d'utilisation

<StyledComponent style={{backgroundColor: 'green'}} contentStyle={{border: 'none'}} />

```
---

## Cycle de vie et méthodes

https://reactjs.org/docs/react-component.html

---

### Séquence de montage

1. `constructor(nextProps, currentState)`
2. `static getDerivedStateFromProps(nextProps, currentState)`
3. `render()`
4. `componentDidMount()`

---

### Séquence de mise à jour

1. `static getDerivedStateFromProps(nextProps, currentState)`
2. `shouldComponentUpdate(nextProps, nextState)`
3. `render()`
4. `getSnapshotBeforeUpdate(prevProps, prevState)`
5. `componentDidUpdate(prevProps, prevState, snapshot)`

---

### Séquence de démontage

1. `componentWillUnmount()`

---

### Gestion des erreurs

`componentDidCatch(error, info)`

Permet d'intercepter les erreurs qui pourraient être émises dans l'arbre des sous composants.

---

## Composants "contrôlés" (formulaires)

```jsx
// my-app/src/components/my-form.js
import React from 'react'

export default class MyForm extends React.Component {

  constructor(props) {
    super(props);
    this.state = {name: ''};
    this.handleChange = this.handleChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
  }

  handleChange(evt) {
    this.setState({value: evt.target.value});
  }

  handleSubmit(evt) {
    console.log(`Votre nom est ${this.state.name}`);
    evt.preventDefault();
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <label>
          Nom:
          <input type="text" value={this.state.name} onChange={this.handleChange} />
        </label>
        <input type="submit" value="Soumettre" />
      </form>
    );
  }
}
```
---

## Intégrer React avec d'autres librairies

https://reactjs.org/docs/integrating-with-other-libraries.html

---

## Tester ses composants

https://facebook.github.io/jest/docs/en/tutorial-react.html

### Installer Jest

```bash
npm install --save-dev jest react-test-renderer babel-jest
```

### Configurer le script NPM

```js
// package.json
{
  // ...
  "scripts" : {
    "test": "jest"
  }
  //...
}
```
---

## Créer un script de test

```
// my-app/src/components/my-styled-component.test.js

/* globals test, expect */
import MyStyledComponent from './my-styled-component'
import renderer from 'react-test-renderer'

test('Navbar snapshot', () => {
  const component = renderer.create(<MyStyledComponent />)
  
  let tree = component.toJSON()
  
  // Vérifier que le composant n'a pas changé depuis le dernier
  // snapshot.
  // Voir https://facebook.github.io/jest/docs/en/snapshot-testing.html
  // pour plus d'informations
  expect(tree).toMatchSnapshot()
  
  // L'API expect() de Jest est disponible à l'adresse
  // https://facebook.github.io/jest/docs/en/expect.html
  
  // Il est possible d'effectuer des vérifications plus avancées
  // grâce au projet Enzyme (vérification du DOM, etc)
  // Voir http://airbnb.io/enzyme/ et
  // https://facebook.github.io/jest/docs/en/tutorial-react.html#dom-testing

})
```

---

### Lancer les tests

```bash
npm test
```

---

## Gestion des routes

https://reacttraining.com/react-router/web/

### Installation du module

```bash
npm install --save react-router react-router-dom
```

---

### Déclaration basique des routes

```
// my-app/src/app.js

import React from 'react'
import { HashRouter } from 'react-router-dom' // ou BrowserRouter
import { Route, Switch, Redirect } from 'react-router'
import { Clock, MyStyledComponent, My404Page } from './components'

export default class App extends React.Component {

  render() {
    <HashRouter>
      <Switch>
      
      	<Route path='/clock' exact component={Clock} />
      	<Route path='/styled' exact component={MyStyledComponent} />
        
        <Redirect from='/old-style' to='/styled' />
        
        <Route component={My404Page} />
        
      </Switch>
    </HashRouter>
  }

}
```

---

## Liens de navigation

```
// my-app/src/components/my-linked-component.js

import React from 'react'
import { Link } from 'react-router'

export default class App extends React.Component {
  render() {
    return (
      <div>
        <Link to='/styled' />
      </div>
    )
  }
}
```
---

## Paramètres de route

```
// my-app/src/app.js

import React from 'react'
import { HashRouter } from 'react-router-dom' // ou BrowserRouter
import { Route } from 'react-router'
import { MyRouteParameter } from './components'

export default class App extends React.Component {
  render() {
    <HashRouter>
      <Route path='/:myParam' component={MyRouteParameter} />
    </HashRouter>
  }
}
```
```
// my-app/src/components/my-route-parameter.js

import React from 'react'
import { HashRouter } from 'react-router-dom' // ou BrowserRouter
import { Route } from 'react-router'
import { MyRouteParameter } from './components'

export default class App extends React.Component {
  render() {
    const { match } = this.props
    <span>Paramètre: { match.params.myParam }</span>
  }
}
```

---

### Sous routes

```
// my-app/src/app.js

import React from 'react'
import { HashRouter } from 'react-router-dom'
import { Route, Switch } from 'react-router'
import { ProductPage } from './components'

export default class App extends React.Component {
  render() {
    <HashRouter>
      <Switch>
      	<Route path='/products/:productId' component={ProductPage} />
      </Switch>
    </HashRouter>
  }
}
```
```
// my-app/src/component/product.js

import React from 'react'
import { Route, Switch } from 'react-router'
import ProductView from './product-view'
import ProductEdit from './product-edit'

export default class ProductPage extends React.Component {
  render() {
    const { match } = this.props
    return (
      <Switch>
        <Route path={`${match.url}/view`} component={ProductView} />
        <Route path={`${match.url}/edit`} component={ProductEdit} />
      </Switch>
    )
  }
}
```
---

## Et plus encore...

- [Transitions](https://reacttraining.com/react-router/web/example/animated-transitions)
- [Liens personnalisés](https://reacttraining.com/react-router/web/example/custom-link)
- [Chemins récursifs...](https://reacttraining.com/react-router/web/example/recursive-paths)

---

## État de l'application

---

## Architecture Flux

![center 100%](img/flux-simple-f8-diagram-with-client-action-1300w.png)

---

## Redux

https://redux.js.org/

---

![bg right:75% contain](./img/redux_flow.png?1)

## Flot des données

---

## `store`, `actions` et `reducers`

### Générateurs d'actions

```js
// actions.js

export const ADD_PRODUCT = 'ADD_PRODUCT'

export function addProduct (name, price) {
  return {type: ADD_PRODUCT, product: {name, price}}
}

export const REMOVE_PRODUCT = 'REMOVE_PRODUCT'

export function removeProduct (name) {
  return {type: REMOVE_PRODUCT, productName: name}
}
```

---

### "Reducer" racine

```js
// reducer.js

export default const rootReducer = (state, action) => {
  
  console.log(`Action: ${JSON.stringify(action)}`)
  
  switch (action.type) {
  	
    case ADD_PRODUCT:
      // L'action est de type ADD_PRODUCT
      // On ajoute le produit dans la liste et
      // on retourne un nouvel état modifié
      return {
        products: [...state.products, action.product]
      }
      
    
    case REMOVE_PRODUCT:
      // L'action est de type REMOVE_PRODUCT
      // On filtre la liste des produits et on
      // retourne un nouvel état modifié
      return {
        products: state.products.filter(p => p.name !== action.productName)
      }
  }
  
  // Si l'action n'est pas gérée, on retourne l'état
  // sans le modifier
  return state
  
}
```

---

## Création du `store`
```javascript
// store.js

import { createStore } from 'redux'
import rootReducer from './reducer'

export function configureStore(initialState = { products: [] }) {
  return createStore(
    rootReducer,
    initialState
  )
}
```
---
## Tester

```js
// store.test.js
/* globals test, expect, jest */
import { addProduct, removeProduct } from './actions'
import { configureStore } from './store'

test('Ajout/suppression des produits', () => {
  // On crée une instance de notre store
  // avec le state par défaut
  const store = configureStore()
  
  // On crée un "faux" subscriber
  // pour vérifier que l'état du store
  // a bien été modifié le nombre de fois voulu
  const subscriber = jest.fn()
  
  // On attache notre faux subscriber
  // au store
  store.subscribe(subscriber)
  
  // On "dispatch" nos actions
  store.dispatch(addProduct('pomme', 5))
  store.dispatch(addProduct('orange', 7))
  store.dispatch(removeProduct('pomme'))
  
  // On s'assure que notre subscriber a bien été
  // appelé
  expect(subscriber).toHaveBeenCalledTimes(3)

  const state = store.getState()
  
  // On s'assure que l'état du store correspond
  // à ce qu'on attend
  expect(state).toMatchObject({
    products: [
      {name: 'orange', price: 7}
    ]
  })
  
})
```

---

## Actions asynchrones

https://github.com/gaearon/redux-thunk

```bash
npm install --save redux-thunk
```
---

### Ajout du middleware au `store`

```
// store.js

import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import rootReducer from './reducer'

export function configureStore(initialState = { products: [] }) {
  return createStore(
    rootReducer,
    initialState,
    applyMiddleware(thunk) 	// On ajoute le middleware 'thunk'
  )
}
```
---

## Générateur d'actions asynchrones

```
// actions.js

export const SAVE_PRODUCT = 'SAVE_PRODUCT'
export const SAVE_PRODUCT_SUCCESS = 'SAVE_PRODUCT_SUCCESS'
export const SAVE_PRODUCT_FAILURE = 'SAVE_PRODUCT_FAILURE'

export function saveProduct (product) {
  return (dispatch, getState) => {
  
    dispatch({type: SAVE_PRODUCT, product})
    
    return fetch('http://my-api/products', {
    	method: 'POST',
        body: JSON.stringify(product)
      })
      .then(res => res.json())
      .then(res => dispatch({ type: SAVE_PRODUCT_SUCCESS, product: res.data }))
      .catch(error => dispatch({ type: SAVE_PRODUCT_FAILURE, error }))
  }
}
```

---

## Utilisation avec React

### Installer les dépendances
```bash
npm install --save react-redux
```

---

### Déclaration de l'usage du `store` dans l'application

```
// my-app/src/index.js

import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import App from './app'
import { configureStore } from './store'

const store = configureStore()

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('app')
)

```
---
```
// my-app/src/app.js

import React from 'react'
import { addProduct } from './actions'
import { connect } from 'react-redux'

class App extends React.Component {

  componentDidMount() {
    // Toutes les 2 secondes, on "dispatch" une nouvelle
    // action qui va ajouter un produit dans le store
    setInterval(() => {
      this.props.dispatch(addProduct(`My product #${Date.now()}`, Math.random()*10|0))
    }, 2000)
  }

  render() {
    // On crée une liste à partir des éléments présents
    // dans le tableaux de produits
    const items = this.props.products.map((p, i) => {
      return <li key={i}>{p.name}</li>
    })
    return (
      <ul>
        {items}
      </ul>
    )
  }
  
}

// Cette fonction permet de "sélectionner" les éléments
// qui nous intéressent dans le state et de les exposer
// en tant que "props" sur notre composant
const mapStateToProps = state => {
  return {
    products: state.products
  }
}

// On "emballe" notre composant dans un "higher order component" (ou H.O.C.) 
// ce qui nous permet de récupérer les données à partir du store
export default connect(mapStateToProps)(App)
```

---

## Redux-Saga

ou comment gérer les **"effets de bord"**

---

![bg right:75% contain](./img/reduxsaga_flow.png?1)

## Flot des données

---

### Cas d'usage

React-Redux permet d'intégrer au sein du "flux de travail" Redux la gestion des opérations asynchrones.

**Exemple** _Appels à des API distantes, opérations liées au passage du temps, messages websockets..._

Notamment, il offre une gestion de la **synchronisation d'opérations asynchrones** i.e. je dois attendre que l'opération A et l'opération B soient terminées pour effectuer l'opération C.

---

### Installation de la dépendance

```bash
npm install --save redux-saga
```

---

### Ajout du middleware

```js
// src/store/store.js
import { createStore, applyMiddleware, combineReducers, compose } from 'redux'
import myReducer from '../reducers/my'
import rootSaga from '../sagas/root'
import createSagaMiddleware from 'redux-saga'

const sagaMiddleware = createSagaMiddleware()

const rootReducer = combineReducers({
  my: myReducer,
});

export function configureStore(initialState = {}) {
    const store = createStore(
        rootReducer,
        initialState,
        compose(
            applyMiddleware(sagaMiddleware)
        )
    )
    sagaMiddleware.run(rootSaga);
    return store;
}
```

---

## Création de la saga "racine"

_Avec une saga "factice" d'authentification_

```js
// src/sagas/root.js
import { all, takeLatest } from 'redux-saga/effects';
import { LOGIN_REQUEST } from '../actions/auth';
import { loginSaga } from './auth';

export default function* rootSaga() {
  yield all([
    takeLatest(LOGIN_REQUEST, loginSaga),
  ]);
}
```

---

## Définition des actions

```js
// src/actions/auth.js
export const LOGIN_REQUEST = 'LOGIN_REQUEST'
export const LOGIN_SUCCESS = 'LOGIN_SUCCESS';
export const LOGIN_FAILURE = 'LOGIN_FAILURE';

export function login(username, password) {
  return { type: LOGIN_REQUEST, username, password }
}

export function loginFailure(username, error) {
  return { type: LOGIN_FAILURE, username, error }
}

export function loginSuccess(username) {
  return { type: LOGIN_SUCCESS, username }
}
```

---

## Création de ma saga d'authentification

```js
// src/sagas/auth.js
import { call, put } from 'redux-saga/effects';
import { loginFailure, loginSuccess } from '../actions/auth';

export default function* loginSaga(action) {
  let result;
  try {
    result = yield call(doLogin, action.username, action.password);
  } catch(err) {
    yield put(loginFailure(action.username, err));
  }
  
  if ('error' in result) {
    yield put(loginFailure(action.username, result.error));
    return
  }
  
  yield put(loginSuccess(action.username));
}

function doLogin(username, password) {
  return fetch('http://my-backend.org/login', {
    method: 'POST',
    body: JSON.stringify({
      Username: username,
      Password: password
    }),
    mode: 'cors',
    credentials: 'include'
  }).then(res => res.json())
}
```

---

## Mise en application

---

### Contexte

Implémenter une application de type "catalogue" comprenant les fonctionnalités suivantes:

- Créer/modifier/supprimer des fiches "produit"
- Trouver des produits via un système de recherche multicritères
- Un mécanisme d'authentification par mot de passe
- Un système de rôles basiques:
  - Un rôle "administrateur" pouvant effectuer toutes les actions
  - Un rôle "éditeur" pouvant créer/modifier/supprimer des fiches existantes
  - Un rôle "consultant" permettant de lire/rechercher des fiches existantes

---

### Contraintes

Vous pouvez sélectionner n'importe quel type de produit à implémenter (album de musique, collection de pins, bestiaire de jeu de rôle...). Voici cependant les différents types d'attributs qui devront être présents sur une fiche produit:

- Un attribut de type numérique
- Un attribut de type "liste de mots clés"
- Un attribut de type "texte libre"
- Un attribut de type "fichier"

La partie "backend" est laissée à votre libre choix.

---

# Licence

## CC BY-NC-SA 3.0 FR

[Creative Commons - Attribution - Pas d’Utilisation Commerciale - Partage dans les Mêmes Conditions 3.0 France](https://creativecommons.org/licenses/by-nc-sa/3.0/fr/)