Mise en place d'un flux infini Symfony-React

Mise en place d'un flux infini Symfony-React


Pour rendre l'expérience utilisateur de nos applications web toujours plus agréable, nous sommes de plus en plus obligés d'utiliser plusieurs technologies en même temps. C'est par exemple le cas si l'on souhaite mettre en place un flux infini. Pour le rendre simple et performant, nous allons utiliser un backend Symfony et un front en ReactJs. La question se pose alors : comment interfacer les deux technos ?

Mise en place du backend

Notre site est tout d'abord un site en Symfony 3.3. La mise en place est assez basique, il vous suffit d'installer Symfony en suivant le tutoriel suivant sur le site officiel. Pour la suite de notre projet, nous allons avoir besoin de stocker les données du flux, pour cela nous allons mettre en place une base de données Postgresql. Il vous suffit de changer dans votre fichier de configuration les paramètres par défaut de la database doctrine.

# config.yml # Doctrine Configuration doctrine: dbal: driver: pdo_pgsql host: '%database_host%' port: '%database_port%' dbname: '%database_name%' user: '%database_user%' password: '%database_password%' charset: UTF8 orm: auto_generate_proxy_classes: '%kernel.debug%' naming_strategy: doctrine.orm.naming_strategy.underscore auto_mapping: true

Comme nous aimons le code propre, nous allons utiliser les variables d'environnement pour notre configuration de base de donnée. Il vous faut alors changer dans votre fichier parameters.yml les valeurs des variables pour aller chercher les valeurs dans votre environnement.

# parameters.yml parameters: database_host: '%env(POSTGRES_HOST)%' database_port: '%env(POSTGRES_PORT)%' database_name: '%env(POSTGRES_DB)%' database_user: '%env(POSTGRES_USER)%' database_password: '%env(POSTGRES_PASSWORD)%' secret: '%env(SECRET)%'

Vous pouvez maintenant créer votre fichier .env avec les valeurs de vos variables.

// .env # PATH DIR SYMFONY_APP_PATH=./ LOGS_DIR=./docker/logs # DATABASE POSTGRES_HOST=postgres POSTGRES_DB=infinite POSTGRES_USER=infinite POSTGRES_PASSWORD=infinitepass POSTGRES_PORT=5432 # PORT WEB WEB_PORT=80 # SYMFONY SECRET=d3e2fa9715287ba25b2d0fd41685ac031970f555

Si vous avez fait un peu attention, vous avez vu que dans le fichier .env il y a d'autres variables, c'est parce que l'application utilise docker.

Mettez en place votre docker (optionnel)

Pour aller plus vite dans la suite de notre projet, nous avons mis en place une architecture docker permettant d'utiliser l'application. La mise en place est optionnelle mais vous aidera pour avancer dans votre développement.

À la racine de votre projet, ajoutez un dossier docker qui contiendra la configuration de votre stack technique. Pour le projet nous allons utiliser :

  1. Php7 pour la partie symfony
  2. Nodejs pour builder l'application React
  3. Nginx comme serveur web pour servir le site

Vous devez créer les trois dossiers suivants à l'intérieur du dossier docker.

  • nginx
  • php7-fpm
  • node

Respectivement dans chaque dossier vous devez ajouter les fichiers Dockerfile suivants.

Dans le fichier nginx/Dockerfile

/nginx/Dockerfile FROM debian:jessie MAINTAINER Maxence POUTORD <maxence.poutord@gmail.com> RUN apt-get update && apt-get install -y \ nginx ADD nginx.conf /etc/nginx/ ADD symfony.conf /etc/nginx/sites-available/ RUN ln -s /etc/nginx/sites-available/symfony.conf /etc/nginx/sites-enabled/symfony RUN rm /etc/nginx/sites-enabled/default RUN echo "upstream php-upstream { server php:9000; }" > /etc/nginx/conf.d/upstream.conf RUN usermod -u 1000 www-data CMD ["nginx"] EXPOSE 80 EXPOSE 443

Dans le fichier php/Dockerfile

/php/Dockerfile # See https://github.com/docker-library/php/blob/4677ca134fe48d20c820a19becb99198824d78e3/7.0/fpm/Dockerfile FROM php:7.1-fpm MAINTAINER Maxence POUTORD <maxence.poutord@gmail.com> RUN apt-get update && apt-get install -y \ git \ unzip \ zlib1g-dev \ libpq-dev # Install Composer RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer RUN composer --version RUN mkdir /var/www/.composer && chown -R www-data /var/www/.composer # Set timezone RUN rm /etc/localtime RUN ln -s /usr/share/zoneinfo/Europe/Paris /etc/localtime RUN "date" # Type docker-php-ext-install to see available extensions RUN docker-php-ext-install pdo pdo_pgsql zip # install xdebug RUN pecl install xdebug RUN docker-php-ext-enable xdebug RUN echo "error_reporting = E_ALL" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini RUN echo "display_startup_errors = On" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini RUN echo "display_errors = On" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini RUN echo "xdebug.remote_enable=1" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini RUN echo "xdebug.remote_connect_back=1" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini RUN echo "xdebug.idekey=\"PHPSTORM\"" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini RUN echo "xdebug.remote_port=9001" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini RUN echo 'alias sf3="php bin/console"' >> ~/.bashrc RUN usermod -u 1000 www-data WORKDIR /var/www/symfony

Dans le fichier node/Dockerfile

/node/Dockerfile FROM node:8 RUN apt-get update && apt-get install -y \ curl \ apt-transport-https RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - && \ echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list RUN apt-get update && apt-get install yarn WORKDIR /var/www/symfony

Il vous faut aussi ajouter la configuration de nginx. Dans le dossier nginxvous devez ajouter la configuration par défaut suivante.

/nginx/ngin.conf user www-data; worker_processes 4; pid /run/nginx.pid; events { worker_connections 2048; multi_accept on; use epoll; } http { server_tokens off; sendfile on; tcp_nopush on; tcp_nodelay on; keepalive_timeout 15; types_hash_max_size 2048; include /etc/nginx/mime.types; default_type application/octet-stream; access_log off; error_log off; gzip on; gzip_disable "msie6"; include /etc/nginx/conf.d/*.conf; include /etc/nginx/sites-enabled/*; open_file_cache max=100; } daemon off;

Puis ajouter la configuration de votre application symfony.

/nginx/symfony.conf server { server_name infinite.dev; root /var/www/symfony/web; location / { try_files $uri @rewriteapp; } location @rewriteapp { rewrite ^(.*)$ /app.php/$1 last; } location ~ ^/(app|app_dev|config)\.php(/|$) { fastcgi_pass php-upstream; fastcgi_split_path_info ^(.+\.php)(/.*)$; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param HTTPS off; } error_log /var/log/nginx/symfony_error.log; access_log /var/log/nginx/symfony_access.log; }

Il ne vous reste plus qu'à ajouter le fichier docker-compose.ymlà la racine de votre projet avec la configuration suivante.

version: '2' services: postgres: image: postgres:9.6 ports: - ${POSTGRES_PORT}:5432 volumes: - ./.data/pgdata:/var/lib/postgresql/data environment: POSTGRES_DB: ${POSTGRES_DB} POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} php: build: docker/php7-fpm env_file: ./.env volumes: - ${SYMFONY_APP_PATH}:/var/www/symfony links: - postgres nginx: build: docker/nginx ports: - ${WEB_PORT}:80 volumes_from: - php volumes: - ${LOGS_DIR}/nginx/:/var/log/nginx node: build: docker/node volumes: - ${SYMFONY_APP_PATH}:/var/www/symfony command: bash -c "yarn install && yarn watch"

Ajoutez la ligne suivante dans votre /etc/hosts

120.0.0.1 infinite.dev

Si vous lancez un docker-compose up puis allez sur la page suivante infinite.dev, vous devriez voir la page suivante vous indiquant que votre site est bien configuré.

Configuration

Une partie de la configuration est tirée de l'article de Maxence Poutord disponible ici.

Vous pouvez aussi retrouver le code directement dans le projet Infinite github dans la branche configuration.

Mettre en place React

Nous allons utiliser React/Redux pour mettre en place notre flux infini. Comme tous les projets node la première chose à faire est d'ajouter à la racine de votre projet le fichier package.json. Comme nous utiliserons Babel pour la compilation de l'ES2105 il faut le mettre dans la configuration du projet, ainsi que l'utilisation EsLint parce que même dans un tutoriel nous faisons les choses proprement. Bien sur il nous faut aussi React et Redux pour avoir notre configuration au complet. Vous pouvez alors ajouter l'ensemble dans votre fichier package.json

{ "name": "infinite", "version": "0.0.1", "engines": { "node": "6.x" }, "private": true, "description": "Infinite flux", "main": "index.js", "scripts": { "build": "webpack --config app/config/webpack.config.js -p", "lint": "eslint src app/config --ext .js --ext .jsx", "lint:fix": "npm run lint -- --fix", "test": "jest", "test:coverage": "jest --coverage", "watch": "webpack --config app/config/webpack.config.js --devtool source-map --debug --watch --display-error-details", }, "repository": { "type": "git", "url": "git+https://github.com/captainjojo/infinite.git" }, "bugs": { "url": "https://github.com/captainjojo/infinite/issues" }, "homepage": "https://github.com/captainjojo/infinite#readme", "devDependencies": { "babel-core": "^6.14.0", "babel-jest": "^15.0.0", "babel-loader": "^6.2.5", "babel-preset-react": "^6.11.1", "eslint": "^3.2.0", "eslint-config-airbnb": "^10.0.1", "eslint-import-resolver-webpack": "^0.8.0", "eslint-plugin-import": "^1.14.0", "eslint-plugin-jsx-a11y": "^2.2.1", "eslint-plugin-react": "^6.2.0", "jest": "^18.0.0", "react-test-renderer": "^15.3.1" }, "dependencies": { "babel-polyfill": "^6.16.0", "babel-preset-es2015": "^6.18.0", "clean-webpack-plugin": "^0.1.14", "lodash": "^4.15.0", "react": "^15.3.1", "react-dom": "^15.3.1", "react-redux": "^4.4.5", "redux": "^3.6.0", "redux-thunk": "^2.1.0", "release-it": "^2.5.1", "webpack": "^v2.2.0-rc.3", "whatwg-fetch": "^2.0.0" }, "jest": { "cacheDirectory": "var/jest", "coverageDirectory": "build/coverage/jest", "moduleFileExtensions": [ "js", "jsx" ], "transformIgnorePatterns": [ "/node_modules/(?!o-.*)/" ], "moduleNameMapper": { "^actions(.*)": "<rootDir>/src/AppBundle/Resources/scripts/js/react/actions$1", "^components(.*)": "<rootDir>/src/AppBundle/Resources/scripts/js/react/components$1", "^containers(.*)": "<rootDir>/src/AppBundle/Resources/scripts/js/react/containers$1", "^helpers(.*)": "<rootDir>/src/AppBundle/Resources/scripts/js/helpers$1", "^lib(.*)": "<rootDir>/src/AppBundle/Resources/scripts/js/lib$1", "^reducers(.*)": "<rootDir>/src/AppBundle/Resources/scripts/js/react/reducers$1", "^store(.*)": "<rootDir>/src/AppBundle/Resources/scripts/js/react/store$1" }, "testRegex": ".*.(test|spec).js[x]?$" } }

Comme vous pouvez le constater nous allons utiliser Webpack pour compiler et préparer nos fichiers javascript. Vous avez aussi en fin du fichier la configuration de Jest qui sera l'outil qui va nous permettre de mettre en place les tests unitaires et les tests visuels de notre application javascript. La configuration Jest permet ici de mapper les noms des modules lors d'un import javascript avec l'emplacement des fichiers.

Il faut donc ajouter les fichiers pour la configuration de Babel et d'Eslint. Pour Babel il vous faut ajouter le fichier .babelrc à la racine de votre projet.

{ "presets": ["es2015", "react"] }

Pour la configuration d'Eslint il vous faut ajouter le fichier .eslintrc.yml à la racine de votre projet.

--- extends: "airbnb" env: node: true browser: true jest: true settings: import/resolver: webpack: config: 'app/config/webpack.config.js'

Maintenant la grande question qu'il faut se poser c'est où mettre en place l'architecture javascript pour qu'elle communique et interagisse facilement avec Symfony ? La décision n'a pas été facile et n'est pas forcément la meilleure.

Commençons par la mise en place de la configuration webpack, nous la placerons dans le dossier app/config de Symfony. Vous devez ajouter le fichier webpack.config.jssuivant :

const _ = require('lodash'); const fs = require('fs'); const resolve = require('path').resolve; const webpack = require('webpack'); const CleanWebpackPlugin = require('clean-webpack-plugin'); const env = process.env.NODE_ENV === 'prd' ? 'production' : 'development'; const root = `${__dirname}/../../`; const paths = { assets: resolve(root, 'src/AppBundle/Resources/views/Assets/'), scripts: resolve(root, 'web/scripts/js'), context: resolve(root, 'src/AppBundle/Resources/scripts/js/'), }; const manifestPlugin = (file, path) => ({ apply: (compiler) => { compiler.plugin('done', (stats) => { fs.writeFileSync( resolve(path, file), JSON.stringify( _.mapValues( stats.toJson().assetsByChunkName, (chunk) => (env === 'development' ? chunk[0] : chunk)), null, '\t' ) ); }); }, }); const config = { context: paths.context, entry: { /** * Contain all the vendors entries * * Vendors library (React, Lodash, ...) * Polyfills */ vendor: [ // Polyfills 'core-js/es6/object', 'core-js/es6/promise', 'whatwg-fetch', // Vendors 'lodash/isEqual', 'react', 'react-dom', 'react-redux', 'redux', 'redux-thunk', ], /** * Each of the following entries represent the single entrypoints * for a given page type */ home: [ 'entrypoints/latest_news_home.jsx', ], }, module: { loaders: [ { test: /\.jsx?$/, exclude: /node_modules\/(?!o-.*)/, loader: 'babel-loader', query: { presets: ['react', ['es2015', { modules: false }]], }, }, ], }, output: { filename: '[name].[chunkhash].js', path: paths.scripts, }, performance: { hints: env === 'production' ? 'warning' : false, }, plugins: [ new webpack.optimize.CommonsChunkPlugin({ names: ['vendor', 'inlined'], minChunks: Infinity, }), new CleanWebpackPlugin(['**'], { root: paths.scripts, }), new webpack.DefinePlugin({ 'process.env': { NODE_ENV: JSON.stringify(env), }, }), manifestPlugin('manifest.json', resolve(root, 'var/webpack/')), ], resolve: { alias: { actions: 'react/actions', components: 'react/components', containers: 'react/containers', helpers: 'helpers', lib: 'lib', reducers: 'react/reducers', store: 'react/store', }, extensions: ['.js', '.jsx'], modules: [ 'node_modules', './src/AppBundle/Resources/scripts/js', ], }, target: 'web', }; if (env === 'production') { config.plugins.push( new webpack.LoaderOptionsPlugin({ minimize: true, }) ); config.plugins.push( new webpack.optimize.UglifyJsPlugin({ minimize: true, compress: { negate_iife: true, unused: true, dead_code: true, drop_console: true, warnings: false, }, output: { comments: false }, }) ); } module.exports = config;

Puis comme pour l'ensemble des fichiers javascript d'un projet javascript, nous allons les mettre dans les ressources du bundle Symfony.

Nous allons créer la coquille d'une architecture Rect/Redux à l'intérieur du dossier src/AppBundle/Resources/scripts/js/react pour cela, créez les dossiers suivants :

  • actions
  • containers
  • reducers
  • store

Dans chaque dossier nous allons commencer à créer notre architecture. Le but du tutoriel n'étant pas la compréhension d'une architecture React/Redux, si vous le souhaitez je vous invite à lire ceci.

Ajoutez le fichier index.js dans le dossier store avec le code suivant.

import { createStore, applyMiddleware } from 'redux'; import thunk from 'redux-thunk'; import reducers from 'reducers'; export default function configureStore(initial) { return createStore(reducers, initial, applyMiddleware(thunk)); }

Le fichier est assez simple et permet seulement de gérer votre store.

Ajoutez le fichier index.js dans le dossier reducers avec le code suivant :

import { combineReducers } from 'redux'; import latestNews from './latest_news'; export default combineReducers({ latestNews, });

Nous allons créer le premier reducers du projet qui ensuite contiendra les changements d'état de notre application. Ajoutez le fichier latest_news.js avec le code suivant :

import actions from 'actions'; export default function latestNews(state = {}, action) { switch (action.type) { default: return state; } }

Vous pouvez maintenant créer les fichiers d' actions en commençant par le fichier index.js

import * as latestNews from './latest_news'; const actions = { latestNews, }; export default actions;

Puis le fichier latest_news.js qui sera vide.

Terminons par l'affichage en créant un fichier jsx très simple dans le dossier containers , ajoutez le fichier LatestNewsHome.jsx qui contiendra l'affichage.

import React, { Component, PropTypes } from 'react'; import { connect } from 'react-redux'; import actions from 'actions'; class LatestNewsHome extends Component { constructor() { super(); } componentDidMount() { } render() { return ( <div>Coucou</div> ); } } LatestNewsHome.propTypes = { }; const mapStateToProps = (state) => ({ }); export default connect(mapStateToProps)(LatestNewsHome);

Voilà notre architecture React/Redux terminée, il nous faut maintenant la faire communiquer avec Symfony.

Faire communiquer React et Symfony

Il vous faut un point d'entrée entre React et Symfony pour vous permettre de lancer votre application React. Dans le dossier src/AppBundle/Resources/scripts/js/entrypoints ajoutez un fichier latest_news_home.jsx contenant l'initialisation de votre React.

import React from 'react'; import { render } from 'react-dom'; import { Provider } from 'react-redux'; import LatestNewsHome from 'containers/LatestNewsHome.jsx'; import configureStore from 'store'; const elements = { latest_news: document.getElementById('react-latest-news-home'), }; // eslint-disable-next-line no-underscore-dangle const store = configureStore(window.__INITIAL_STATE__); const component = ( <Provider store={store}> <LatestNewsHome /> </Provider> ); render(component, elements.latest_news);

Comme vous pouvez le voir, nous allons insérer notre composant React dans notre page HTML via la balise avec l'id react-latest-news-home.

Il faut donc dans votre fichier template Twig ajouter cet id.

Pour le tutoriel j'utilise les pages par défaut du projet Symfony, à vous de choisir votre page

Dans la page app/Resources/views/default/index.html.twig changez le block body avec le code suivant.

{% extends 'base.html.twig' %} {% block body %} <div id="react-latest-news-home" > <div> {% endblock %} {% block javascripts %} {% endblock %} {% block stylesheets %} {% endblock %}

Si vous lancez votre site il ne se passe rien... Et oui, il manque les appels javascript.

Si vous lancez un yarn watch vous verrez que les fichiers sont générés dans votre dossier web/scripts/js sous trois formes :

  1. vendor.*.js contenant les librairies React etc...
  2. home.*.js contenant le code de votre composant
  3. inlined.*.js contenant la configuration de webpack

Le cache busting est déjà intégré dans la configuration Webpack.

Il nous faut maintenant ajouter les balises scripts dans votre page twig. La facilité serait de poser la balise suivante :

<script src="http://infinite.dev/scripts/js/inlined.2d4b19e4578c3af04a03.js" defer></script> <script src="http://infinite.dev/scripts/js/vendor.2d4b19e4578c3af04a03.js" defer></script> <script src="http://infinite.dev/scripts/js/home.e428223280fc3028d63f.js" defer></script>

Cela n'est pas très pratique car vous devez changer vos balises à chaque changement de javascript. Nous voulons donc utiliser la fonction asset de twig comme pour tout autre javascript. Vous pouvez mettre dans votre block javascript les deux balises suivantes :

{% block javascripts %} <script src="{{ asset('inlined.js', 'js') }}" defer></script> <script src="{{ asset('vendor.js', 'js') }}" defer></script> <script src="{{ asset('home.js', 'js') }}" defer></script> {% endblock %}

Si vous testez maintenant vous avez deux 404. Effectivement assetne prend pas en compte le cache busting mais nous allons l'aider.

Nous allons créer un service permettant de gérer l'utilisation du cache busting. Dans votre fichier config.yml vous pouvez surcharger la function asset. Si vous voulez mieux comprendre vous pouvez allez lire l'article de Symfony ici.

framework: #esi: ~ #translator: { fallbacks: ['%locale%'] } secret: '%secret%' router: resource: '%kernel.project_dir%/app/config/routing.yml' strict_requirements: ~ form: ~ csrf_protection: ~ validation: { enable_annotations: true } #serializer: { enable_annotations: true } templating: engines: ['twig'] default_locale: '%locale%' trusted_hosts: ~ session: # https://symfony.com/doc/current/reference/configuration/framework.html#handler-id handler_id: session.handler.native_file save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%' fragments: ~ http_method_override: true assets: ~ php_errors: log: true assets: packages: js: base_urls: 'http://infinite.dev/scripts/js' version_strategy: 'app.assets.js.version_strategy'

Puis nous allons ajouter le service dans notre bundle. Commençons par créer le service.yml

services: app.assets.js.version_strategy: class: AppBundle\Service\VersionStrategy\JavascriptBusterVersionStrategy arguments: - '%kernel.root_dir%/../var/webpack/manifest.json'

Nous utiliserons la manifest.json de webpack qui nous permettra de connaître la version actuelle du cache busting.

Il ne vous reste plus qu'à ajouter le fichier src/AppBundle/Service/VersionStrategy/JavascriptBusterVersionStrategy.php avec le code suivant :

<?php namespace AppBundle\Service\VersionStrategy; use Symfony\Component\Asset\VersionStrategy\VersionStrategyInterface; class JavascriptBusterVersionStrategy implements VersionStrategyInterface { /** * The manifest object containing for each entry * the filename containing the version. * * @var array */ private $hashes = []; /** * The manifest file path. * * @var string */ private $path; /** * @param string $path The manifest file path */ public function __construct(string $path) { $this->path = $path; } /** * {@inheritdoc} */ public function getVersion($asset): string { $this->ensureHashesLoaded(); preg_match( // Matches pattern like 'vendor.67e29947398bcbf9b383.js' '/(?:.*)\.([[:alnum:]]*)\.js/', $this->hashes[$this->getEntryName($asset)] ?? '', $matches ); return $matches[1] ?? ''; } /** * {@inheritdoc} */ public function applyVersion($asset): string { $this->ensureHashesLoaded(); return $this->hashes[$this->getEntryName($asset)] ?? ''; } /** * Return the manifest entry name for the given asset. * * @return string */ private function getEntryName($path): string { // Replace pattern like 'vendor.js' into 'vendor' return preg_replace( '/(.*)\.js/', '$1', $path ); } /** * Load hashes from manifest if needed. */ private function ensureHashesLoaded() { if (empty($this->hashes)) { $this->hashes = json_decode(file_get_contents($this->path), true); } } }

Vous n'avez normalement plus de 404, mais une erreur javascript signalant que vous n'avez pas d'__INITIAL_STATE__ pour votre composant React.

Nous allons donc le configurer dans votre fichier Twig, en ajoutant une valeur par défaut.

{% block javascripts %} {% set initial_state = {latestNews: {}} %} <script>window.__INITIAL_STATE__ = {{ initial_state|json_encode|raw }};</script> <script src="{{ asset('inlined.js', 'js') }}" defer></script> <script src="{{ asset('vendor.js', 'js') }}" defer></script> <script src="{{ asset('home.js', 'js') }}" defer></script> {% endblock %}

Vous pouvez aussi retrouver le code directement dans le projet Infinite github dans la branche communication.

On code le flux infini

Partie Symfony

Dans ce flux nous allons mettre des articles contenant seulement une date et un titre. Nous aurons besoin d'initialiser le composant React avec les X premiers articles, puis pour chaque passage sur le voir plus d'un appel vers un webservice qui nous renverra les articles suivants.

Pour ce tutoriel, nous allons seulement utiliser des fixtures à vous de jouer pour le reste.

Nous allons créer l'Entityarticle en ajoutant le fichier src/AppBundle/Entity/Article.php avec le code suivant :

<?php namespace AppBundle\Entity; use Doctrine\ORM\Mapping as ORM; /** * Class Article * @package AppBundle\Entity * * @ORM\Table(name="article") * @ORM\Entity(repositoryClass="AppBundle\Repository\ArticleRepository") */ class Article { /** * @var int $id * * @ORM\Column(name="id", type="integer") * @ORM\Id * @ORM\GeneratedValue(strategy="AUTO") */ private $id; /** * @var string $title * * @ORM\Column(name="title", type="string", nullable=false) */ private $title; /** * @var datetime $created * * @ORM\Column(name="created_at", type="datetime", nullable=false) */ protected $createdAt; /** * @return int */ public function getId(): int { return $this->id; } /** * @param string $title * * @return Article */ public function setTitle(string $title): Article { $this->title = $title; return $this; } /** * @return string */ public function getTitle(): string { return $this->title; } /** * @return \DateTime */ public function getCreatedAt() { return $this->createdAt; } /** * @param \DateTime $createdAt * @return Article */ public function setCreatedAt(\DateTime $createdAt): Article { $this->createdAt = $createdAt; return $this; } }

Ensuite en suivant l'article ici, permettant de mettre en place des fixtures doctrine, vous pouvez ajouter dans votre composer.json

"require-dev": { "doctrine/doctrine-fixtures-bundle": "^2.3" }

Puis dans votre AppKernel.php

<?php use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Config\Loader\LoaderInterface; class AppKernel extends Kernel { public function registerBundles() { $bundles = [ new Symfony\Bundle\FrameworkBundle\FrameworkBundle(), new Symfony\Bundle\SecurityBundle\SecurityBundle(), new Symfony\Bundle\TwigBundle\TwigBundle(), new Symfony\Bundle\MonologBundle\MonologBundle(), new Symfony\Bundle\SwiftmailerBundle\SwiftmailerBundle(), new Doctrine\Bundle\DoctrineBundle\DoctrineBundle(), new Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle(), new AppBundle\AppBundle(), ]; if (in_array($this->getEnvironment(), ['dev', 'test'], true)) { $bundles[] = new Symfony\Bundle\DebugBundle\DebugBundle(); $bundles[] = new Symfony\Bundle\WebProfilerBundle\WebProfilerBundle(); $bundles[] = new Sensio\Bundle\DistributionBundle\SensioDistributionBundle(); $bundles[] = new Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle(); if ('dev' === $this->getEnvironment()) { $bundles[] = new Sensio\Bundle\GeneratorBundle\SensioGeneratorBundle(); $bundles[] = new Symfony\Bundle\WebServerBundle\WebServerBundle(); } } return $bundles; } public function getRootDir() { return __DIR__; } public function getCacheDir() { return dirname(__DIR__).'/var/cache/'.$this->getEnvironment(); } public function getLogDir() { return dirname(__DIR__).'/var/logs'; } public function registerContainerConfiguration(LoaderInterface $loader) { $loader->load($this->getRootDir().'/config/config_'.$this->getEnvironment().'.yml'); } }

Pour terminer, ajoutez le fichier src/AppBundle/DataFixtures/ORM/LoadArticleData.php contenant les données que l'on utilisera pour la suite.

<?php namespace AppBundle\DataFixtures\ORM; use AppBundle\Entity\Article; use Doctrine\Common\DataFixtures\AbstractFixture; use Doctrine\Common\DataFixtures\OrderedFixtureInterface; use Doctrine\Common\Persistence\ObjectManager; /** * Class LoadArticleData * @package AppBundle\DataFixtures\ORM * * * @codeCoverageIgnore */ class LoadArticleData extends AbstractFixture implements OrderedFixtureInterface { /** * @param ObjectManager $manager * * @return void */ public function load(ObjectManager $manager): void { foreach ($this->getData() as $data) { $article = new Article(); $article->setTitle($data['title']); $article->setCreatedAt($data['created_at']); $manager->persist($article); } $manager->flush(); $manager->clear(); } /** * @return int */ public function getOrder(): int { return 1; } /** * @return array */ private function getData(): array { return [ ['title' => 'MIGRER UNE APPLICATION REACT CLIENT-SIDE EN SERVER-SIDE AVEC NEXT.JS', 'created_at' => new \DateTime('03-09-2017')], ['title' => 'VOTRE CI DE QUALITÉ', 'created_at' => new \DateTime('30-08-2017')], ['title' => 'JSON SERVER', 'created_at' => new \DateTime('25-08-2017')], ['title' => 'BUILD AN API WITH API PLATFORM', 'created_at' => new \DateTime('24-08-2017')], ['title' => 'RETOUR SUR UN LIVE-CODING DE DÉCOUVERTE DU LANGAGE GO', 'created_at' => new \DateTime('23-08-2017')], ['title' => 'FEEDBACK ON A LIVE-CODING TO DISCOVER GO LANGUAGE', 'created_at' => new \DateTime('23-08-2017')], ['title' => 'HOW TO CHECK THE SPELLING OF YOUR DOCS FROM TRAVIS CI?', 'created_at' => new \DateTime('18-08-2017')], ['title' => 'COMMENT VÉRIFIER L\'ORTHOGRAPHE DE VOS DOCS DEPUIS TRAVIS CI ?', 'created_at' => new \DateTime('18-08-2017')], ['title' => 'JSON SERVER', 'created_at' => new \DateTime('16-08-2017')], ['title' => 'ANDROID ET LES DESIGN PATTERNS', 'created_at' => new \DateTime('09-08-2017')], ['title' => 'CONTINUOUS IMPROVEMENT: HOW TO RUN YOUR AGILE RETROSPECTIVE?', 'created_at' => new \DateTime('03-08-2017')], ['title' => 'CONSTRUIRE UNE API EN GO', 'created_at' => new \DateTime('26-07-2017')], ['title' => 'CRÉER UNE API AVEC API PLATFORM', 'created_at' => new \DateTime('25-07-2017')], ['title' => 'LES PRINCIPAUX FORMATS DE FLUX VIDEO LIVE DASH ET HLS', 'created_at' => new \DateTime('19-07-2017')], ['title' => 'MIGRATION DU BLOG', 'created_at' => new \DateTime('11-07-2017')], ['title' => 'TAKE CARE OF YOUR EMAILS', 'created_at' => new \DateTime('05-07-2017')], ['title' => 'ENVOYER DES PUSH NOTIFICATIONS VIA AMAZON SNS EN SWIFT 3', 'created_at' => new \DateTime('28-06-2017')], ['title' => 'CONSTRUCT AND STRUCTURE A GO GRAPHQL API', 'created_at' => new \DateTime('15-06-2017')], ['title' => 'IS AMP THE WEB 3.0', 'created_at' => new \DateTime('14-06-2017')], ['title' => 'CONSTRUIRE ET STRUCTURER UNE API GRAPHQL EN GO', 'created_at' => new \DateTime('07-06-2017')], ]; } }

Vous pouvez lancer la commande suivante php bin/console doctrine:fixtures:load pour insérer les données dans votre base de données.

Fonctionnellement nous voulons afficher les articles par ordre de dernière création, l'idée est donc qu'un clic sur le bouton voir plus permette de voir les articles antérieurs.

Mais comment être sur d'avoir les articles les plus anciens ?

En effet, si on utilise seulement l'offset et le limit il se peut qu'un article s'insère dans notre flux. Nous allons donc prendre les articles qui sont plus anciens que le dernier article qui s'est affiché.

Dans le dossier src/AppBundle/Repository/ArticleRepository.php nous allons mettre la queryqu'il faudra utiliser pour récupérer les contenus.

<?php namespace AppBundle\Repository; use Doctrine\ORM\EntityRepository; /** * Class ArticleRepository * @package AppBundle\Repository */ class ArticleRepository extends EntityRepository { public function getArticles(int $lastNewsDate, int $limit) { $lastNewsDateTime = new \Datetime(); $lastNewsDateTime->setTimestamp($lastNewsDate); $qb = $this->createQueryBuilder('b') ->where('b.createdAt < :lastNewsDate') ->setParameter('lastNewsDate', $lastNewsDateTime) ->orderBy('b.createdAt', 'DESC') ->getQuery() ->setMaxResults($limit); return $qb; } }

Maintenant nous allons initialiser le composant React avec les premiers articles. Nous le faisons dans la partie PHP car nous voulons que l'utilisateur n'ayant pas javascript voie tout de même les articles.

Dans votre fichier src/AppBundle/Controller/DefaultController.php vous pouvez changer votre indexAction avec le code suivant

<?php namespace AppBundle\Controller; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\JsonResponse; use AppBundle\Entity\Article; class DefaultController extends Controller { /** * @Route("/", name="homepage") */ public function indexAction(Request $request) { $articles = $this->getDoctrine() ->getRepository(Article::class) ->getArticles(time(), 3) ->getResult(); $jsonArticles = []; foreach ($articles as $article) { $jsonArticles[] = [ 'id' => $article->getId(), 'title' => $article->getTitle(), 'createdAt' => $article->getCreatedAt()->getTimestamp() ]; } return $this->render('default/index.html.twig', [ 'latestNews' => $jsonArticles, 'lastItemDate' => $jsonArticles[count($jsonArticles) - 1]['createdAt'] ]); }

Puis nous allons ajouter le webservice dans ce même fichier.

<?php namespace AppBundle\Controller; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\JsonResponse; use AppBundle\Entity\Article; class DefaultController extends Controller { /**** Index Action ****/ /** * @Route("/ws", name="ws") */ public function wsAction(Request $request) { $lastItemDate = (int) ($request->get('last_item_date') ?? time()); $articles = $this->getDoctrine() ->getRepository(Article::class) ->getArticles($lastItemDate, 3) ->getResult(); $jsonArticles = []; foreach ($articles as $article) { $jsonArticles[] = [ 'id' => $article->getId(), 'title' => $article->getTitle(), 'createdAt' => $article->getCreatedAt()->getTimestamp() ]; } return new JsonResponse($jsonArticles); } }

Dernières choses : faire afficher les articles et initialiser votre React. Il vous faut changer votre fichier app/Resources/views/default/index.html.twig

{% extends 'base.html.twig' %} {% block body %} <div id="react-latest-news-home" > <ul> {% for article in latestNews %} <li> {{ article.title }} </li> {% endfor %} <ul> <div> {% endblock %} {% block javascripts %} {% set initial_state = { latestNews: { data: latestNews, lastItemDate: lastItemDate, limit: 3, }, } %} <script>window.__INITIAL_STATE__ = {{ initial_state|json_encode|raw }};</script> <script src="{{ asset('inlined.js', 'js') }}" defer></script> <script src="{{ asset('vendor.js', 'js') }}" defer></script> <script src="{{ asset('home.js', 'js') }}" defer></script> {% endblock %} {% block stylesheets %} {% endblock %}

Si vous affichez votre site, vous devez voir les trois premiers contenus mais le voir plus ne marchera pas, normal nous n'avons pas fini notre React.

Partie React

Peu de chose à changer, d'abord commençons par changer src/AppBundle/Resources/scripts/js/react/actions/latest_news.js

export const ERROR = 'latest_news/ERROR'; export const FETCH = 'latest_news/FETCH'; export const RECEIVE = 'latest_news/RECEIVE'; export const SHOW = 'latest_news/SHOW'; const error = () => ({ type: ERROR, }); const fetching = (lastItemDate, limit) => ({ lastItemDate, limit, type: FETCH, }); const receive = (news) => ({ type: RECEIVE, data: news, }); const show = () => ({ type: SHOW, }); /** * Fetch the data latest_news data * * @dispatch latest_news/FETCH * @dispatch latest_news/RECEIVE * @dispatch latest_news/ERROR */ export const fetch = () => (dispatch, getState) => { const { isFetching, lastItemDate, limit } = getState().latestNews; if (isFetching) { return Promise.resolve(); } let url = `/app_dev.php/ws?last_item_date=${lastItemDate}&limit=${limit}`; dispatch(fetching(lastItemDate, limit)); return window.fetch(url, { credentials: 'same-origin' }) .then(response => { if (!response.ok) { return Promise.reject(); } return response.json(); }) .then(json => dispatch(receive(json))) .catch(() => dispatch(error())); }; /** * Prefetch the data latest_news data * And display the previously prefetched data * * @dispatch latest_news/ERROR * @dispatch latest_news/FETCH * @dispatch latest_news/RECEIVE * @dispatch latest_news/SHOW */ export const prefetch = () => (dispatch, getState) => { dispatch(show()); return fetch()(dispatch, getState); }; /** * Refresh the latest views to update their diff time */ export const refresh = () => ({ type: REFRESH, });

On y trouve l'appel au webservice lors de l'action Fetch. Pour améliorer les performances nous allons chercher les données lors de l'affichage des données précédentes qui permet d'avoir un coup d'avance sur l'utilisateur.

Vous pouvez maintenant changer le reducers suivant src/AppBundle/Resources/scripts/js/react/reducers/latest_news.js

import actions from 'actions'; export default function latestNews(state = {}, action) { switch (action.type) { case actions.latestNews.FETCH: return Object.assign({}, state, { lastItemDate: action.lastItemDate, limit: action.limit, isFetching: true, }); case actions.latestNews.RECEIVE: return Object.assign({}, state, { data: state.data.concat( action.data.map( (article) => Object.assign({}, article, { visible: false }) ) ), isFetching: false, }); case actions.latestNews.SHOW: return Object.assign({}, state, { data: state.data.map( (article) => { if (article.visible === false) { return Object.assign({}, article, { visible: true }); } return article; } ), // Items on the next page must be published prior to the last article on the current page. lastItemDate: (state.data.length === 0 ? 0 : state.data[state.data.length - 1].createdAt), }); default: return state; } }

Puis terminons les composants. D'abord src/AppBundle/Resources/scripts/js/react/containers/LatestNewsHome.jsx qui est le point d'entrée.

import React, { Component, PropTypes } from 'react'; import { connect } from 'react-redux'; import actions from 'actions'; import ThreadSection from 'components/Organisms/ThreadSection.jsx'; const REFRESH_INTERVAL = 60; // One minute class LatestNewsHome extends Component { constructor() { super(); this.onButtonClick = this.onButtonClick.bind(this); } componentDidMount() { this.props.dispatch(actions.latestNews.fetch()); } onButtonClick(event) { this.props.dispatch(actions.latestNews.prefetch()); } render() { const filtered = this.props.latestNews .filter((article) => article.visible === undefined || article.visible); return ( <ThreadSection articles={filtered} more={!this.props.isFetching} onButtonClick={this.onButtonClick} /> ); } } LatestNewsHome.propTypes = { dispatch: PropTypes.func.isRequired, isFetching: PropTypes.bool, latestNews: PropTypes.arrayOf(PropTypes.object).isRequired, }; const mapStateToProps = (state) => ({ isFetching: state.latestNews.isFetching, latestNews: state.latestNews.data, }); export default connect(mapStateToProps)(LatestNewsHome);

Puis vous pouvez ajouter dans le dossier src/AppBundle/Resources/scripts/js/react/components les composants suivants :

  • src/AppBundle/Resources/scripts/js/react/components/Organisms/ThreadSection.jsx
import React, { PropTypes } from 'react'; import Button from 'components/Atoms/Button.jsx'; const renderThreadItems = (article, index) => { if (!article.id) { return null; } return ( <li key={index}> {article.title} </li> ); }; const ThreadSection = ({ articles = [], more = false, onButtonClick, }) => ( <div> <ul> {articles.map((article, index) => renderThreadItems(article, index))} </ul> {more && ( <div> <Button text="Voir Plus" onClick={onButtonClick} ariaLabel="Afficher les contenus plus anciens" /> </div> )} </div> ); ThreadSection.propTypes = { articles: PropTypes.arrayOf(PropTypes.object), more: PropTypes.bool, onButtonClick: PropTypes.func, }; export default ThreadSection;
  • src/AppBundle/Resources/scripts/js/react/components/Atoms/Button.jsx
import React, { PropTypes } from 'react'; const Button = ({onClick = () => {}, text = '', ariaLabel = '' }) => { const attributes = { onClick, }; if (ariaLabel !== '') { attributes['aria-label'] = ariaLabel; } return (<a {...attributes} > {text} </a> ); }; Button.propTypes = { onClick: PropTypes.func, text: PropTypes.string, ariaLabel: PropTypes.string, }; export default Button;

Bravo vous avez terminé votre flux infini.

Vous pouvez aussi retrouver le code directement dans le projet Infinite github dans la branche master.

Merci d'avoir suivi ce tutoriel, si vous avez des questions surtout n'hésitez pas.

Auteur(s)

Jonathan Jalouzot

Jonathan Jalouzot

Lead développeur au @lemondefr, mes technologies sont le symfony depuis 2009, le nodejs, l'angularjs, rabbitMq etc ... J'adore les médias et aimerai continuer dans ce secteur plein de surprise. Vous pouvez me retrouver sur les réseaux sociaux: Twitter: @captainjojo42 Instagram: @captainjojo42 Linkedin: https://fr.linkedin.com/in/jonathanjalouzot Github: https://github.com/captainjojo

Voir le profil

Vous souhaitez en savoir plus sur le sujet ?
Organisons un échange !

Notre équipe d'experts répond à toutes vos questions.

Nous contacter

Découvrez nos autres contenus dans le même thème

Astronaute revenant de mission

Retour sur le Forum PHP 2024

Découvrez un résumé concis des conférences qui nous ont le plus marqué lors du Forum PHP 2024 !

À la découverte de l'Anchor positioning API

La nouvelle Anchor positioning API en CSS

L'Anchor positioning API est arrivée en CSS depuis quelques mois. Expérimentale et uniquement disponible à ce jour pour les navigateurs basés sur Chromium, elle est tout de même très intéressante pour lier des éléments entre eux et répondre en CSS à des problématiques qui ne pouvaient se résoudre qu'en JavaScript.