Objectifs

Dans cet article nous allons voir ensemble comment mettre en place un onboarding avec Intro.js dans une application Symfony.

Nous ferons en sorte que cet onboarding ne soit visible que lors de la première connexion de l’utilisateur.

Initialisation d’un projet Symfony

  • On initie un nouveau projet Symfony
symfony new introjs --webapp
cd introjs
npm install --force
npm run build
docker-compose up -d
symfony serve -d
  • Création d’une entité User

On prends toute les propositions par défaut

symfony console make:user                             
  • Création d’un controllerHomeController
symfony console make:controller Home
  • Création d’une page de registration
symfony console make:registration   

 Creating a registration form for App\Entity\User

 Do you want to add a @UniqueEntity validation annotation on your User class to make sure duplicate accounts aren't created? (yes/no) [yes]:
 > yes

 Do you want to send an email to verify the user's email address after registration? (yes/no) [yes]:
 > no

 Do you want to automatically authenticate the user after registration? (yes/no) [yes]:
 > no

 What route should the user be redirected to after registration?:
  [12] app_home
 > 12
  • Création d’une page de login
symfony console make:auth 

 What style of authentication do you want? [Empty authenticator]:
  [0] Empty authenticator
  [1] Login form authenticator
 > 1

 The class name of the authenticator to create (e.g. AppCustomAuthenticator):
 > AppAuthenticator

 Choose a name for the controller class (e.g. SecurityController) [SecurityController]:
 > SecurityController

 Do you want to generate a '/logout' URL? (yes/no) [yes]:
 > yes
  • On modifier le fichier src/Security/AppAuthenticator.php
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
    if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
        return new RedirectResponse($targetPath);
    }
    return new RedirectResponse($this->urlGenerator->generate('app_home'));
}
  • On modifie le fichier config/packages/security.yaml
    access_control:
        # - { path: ^/admin, roles: ROLE_ADMIN }
        - { path: ^/home, roles: ROLE_USER }
  • On mets en place Boostrap 5 (via CDN) en modifiant le fichier templates/base.html.twig
<!doctype html>
<html lang="en">
	<head>
		<title>
			{% block title %}Welcome!{% endblock %}
		</title>
		<meta charset="utf-8">
		<meta name="viewport" content="width=device-width, initial-scale=1">

		{% block stylesheets %}
			{{ encore_entry_link_tags('app') }}
			<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
		{% endblock %}

		{% block javascripts %}
			{{ encore_entry_script_tags('app') }}
			<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
		{% endblock %}
	</head>
	<body>
		{% block body %}{% endblock %}
	</body>
</html>
  • On génére et applique les migrations
symfony console make:migration
symfony console d:m:m
  • Et enfin on modifie templates/home/index.html.twig

On mets en place un modèle de mise en page bateau, histoire de …

{% extends 'base.html.twig' %}

{% block title %}Hello HomeController!
{% endblock %}

{% block body %}
	<header>
		<div class="collapse bg-dark" id="navbarHeader">
			<div class="container">
				<div class="row">
					<div class="col-sm-8 col-md-7 py-4">
						<h4 class="text-white">About</h4>
						<p class="text-muted">Add some information about the album below, the author, or any other background context. Make it a few sentences long so folks can pick up some informative tidbits. Then, link them off to some social networking sites or contact information.</p>
					</div>
				</div>
			</div>
		</div>
		<div class="navbar navbar-dark bg-dark shadow-sm">
			<div class="container">
				<a href="#" class="navbar-brand d-flex align-items-center">
					<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="me-2" viewbox="0 0 24 24"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
					<strong>Album</strong>
				</a>
				<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarHeader" aria-controls="navbarHeader" aria-expanded="false" aria-label="Toggle navigation">
					<span class="navbar-toggler-icon"></span>
				</button>
			</div>
		</div>
	</header>

	<main>

		<section class="py-5 text-center container">
			<div class="row py-lg-5">
				<div class="col-lg-6 col-md-8 mx-auto">
					<h1 class="fw-light">Album example</h1>
					<p class="lead text-muted">Something short and leading about the collection below—its contents, the creator, etc. Make it short and sweet, but not too short so folks don’t simply skip over it entirely.</p>
					<p>
						<a href="#" class="btn btn-primary my-2">Main call to action</a>
						<a href="#" class="btn btn-secondary my-2">Secondary action</a>
					</p>
				</div>
			</div>
		</section>

		<div class="album py-5 bg-light">
			<div class="container">

				<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3">
					<div class="col">
						<div class="card shadow-sm">
							<img src="https://picsum.photos/199" alt="">

							<div class="card-body">
								<p class="card-text">This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.</p>
								<div class="d-flex justify-content-between align-items-center">
									<div class="btn-group">
										<button type="button" class="btn btn-sm btn-outline-secondary">View</button>
										<button type="button" class="btn btn-sm btn-outline-secondary">Edit</button>
									</div>
									<small class="text-muted">9 mins</small>
								</div>
							</div>
						</div>
					</div>
                    <div class="col">
						<div class="card shadow-sm">
							<img src="https://picsum.photos/200" alt="">

							<div class="card-body">
								<p class="card-text">This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.</p>
								<div class="d-flex justify-content-between align-items-center">
									<div class="btn-group">
										<button type="button" class="btn btn-sm btn-outline-secondary">View</button>
										<button type="button" class="btn btn-sm btn-outline-secondary">Edit</button>
									</div>
									<small class="text-muted">9 mins</small>
								</div>
							</div>
						</div>
					</div>
                    <div class="col">
						<div class="card shadow-sm">
							<img src="https://picsum.photos/201" alt="">

							<div class="card-body">
								<p class="card-text">This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.</p>
								<div class="d-flex justify-content-between align-items-center">
									<div class="btn-group">
										<button type="button" class="btn btn-sm btn-outline-secondary">View</button>
										<button type="button" class="btn btn-sm btn-outline-secondary">Edit</button>
									</div>
									<small class="text-muted">9 mins</small>
								</div>
							</div>
						</div>
					</div>
				</div>
			</div>
		</div>

	</main>
{% endblock %}

Installation de Intro.js

Passons enfin à la mise en place de la librairie.

  • On install la libraire Intro.js
npm install intro.js --save
  • Créons un fichier assets/intro.js
import 'intro.js/introjs.css';

import introJs from 'intro.js';

introJs().setOptions({
    steps: [{
      intro: "Hello world!"
    }, {
      element: document.querySelector('.test'),
      intro: "Ceci est un test",
    }]
  }).start();
  • Ajoutons notre fichier dans la configuration de Webpack Encore webpack.config.js
.addEntry('intro', './assets/intro.js')
  • Et utilisons cela dans notre fichier templates/base.html.twig
<!doctype html>
<html lang="en">
	<head>
		<title>
			{% block title %}Welcome!{% endblock %}
		</title>
		<meta charset="utf-8">
		<meta name="viewport" content="width=device-width, initial-scale=1">

		{% block stylesheets %}
			{{ encore_entry_link_tags('app') }}
			{{ encore_entry_link_tags('intro') }}
			<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
		{% endblock %}

		{% block javascripts %}
			{{ encore_entry_script_tags('app') }}
			{{ encore_entry_script_tags('intro') }}
			<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
		{% endblock %}
	</head>
	<body>
		{% block body %}{% endblock %}
	</body>
</html>
  • Puis modifions le fichier templates/home/index.html.twig pour tester le bon fonctionnement.
<section class="py-5 text-center container">
	<div class="row py-lg-5">
		<div class="col-lg-6 col-md-8 mx-auto">
			<h1 class="fw-light">Album example</h1>
			<p class="lead text-muted">Something short and leading about the collection below—its contents, the creator, etc. Make it short and sweet, but not too short so folks don’t simply skip over it entirely.</p>
			<p>
				<a href="#" class="btn btn-primary my-2 test">Main call to action</a>
				<a href="#" class="btn btn-secondary my-2">Secondary action</a>
			</p>
		</div>
	</div>
</section>

Créons notre Onboarding

  • Adaptons notre fichier assets/intro.js
import "intro.js/introjs.css";

import introJs from "intro.js";

introJs()
    .setOptions({
        steps: [
            {
                intro: "Bienvenue sur votre espace personnel, nous allons vous guider pour vous aider à prendre en main votre espace personnel.",
            },
            {
                element: document.querySelector(".intro-step1"),
                intro: "Ceci est le nom de votre espace personnel.",
            },
            {
                element: document.querySelector(".intro-step2"),
                intro: "Ceci est le bouton d'action principale de votre espace personnel.",
            },
            {
                element: document.querySelector(".intro-step3"),
                intro: "Ceci est le bouton d'action secondaire de votre espace personnel.",
            },
            {
                element: document.querySelector(".intro-step4"),
                intro: "Ceci est une carte de votre espace personnel.",
            },
            {
                element: document.querySelector(".intro-step5"),
                intro: "Ceci est le bouton View",
            },
        ],
    })
    .start();
  • Adpatons notre fichier templates/home/index.html.twig
{% extends 'base.html.twig' %}

{% block title %}Hello HomeController!
{% endblock %}

{% block body %}
	<header>
		<div class="collapse bg-dark" id="navbarHeader">
			<div class="container">
				<div class="row">
					<div class="col-sm-8 col-md-7 py-4">
						<h4 class="text-white">About</h4>
						<p class="text-muted">Add some information about the album below, the author, or any other background context. Make it a few sentences long so folks can pick up some informative tidbits. Then, link them off to some social networking sites or contact information.</p>
					</div>
				</div>
			</div>
		</div>
		<div class="navbar navbar-dark bg-dark shadow-sm">
			<div class="container">
				<a href="#" class="navbar-brand d-flex align-items-center">
					<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" aria-hidden="true" class="me-2" viewbox="0 0 24 24"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
					<strong>Album</strong>
				</a>
				<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarHeader" aria-controls="navbarHeader" aria-expanded="false" aria-label="Toggle navigation">
					<span class="navbar-toggler-icon"></span>
				</button>
			</div>
		</div>
	</header>

	<main>

		<section class="py-5 text-center container">
			<div class="row py-lg-5">
				<div class="col-lg-6 col-md-8 mx-auto">
					<h1 class="fw-light intro-step1">Album example</h1>
					<p class="lead text-muted">Something short and leading about the collection below—its contents, the creator, etc. Make it short and sweet, but not too short so folks don’t simply skip over it entirely.</p>
					<p>
						<a href="#" class="btn btn-primary my-2 intro-step2">Main call to action</a>
						<a href="#" class="btn btn-secondary my-2 intro-step3">Secondary action</a>
					</p>
				</div>
			</div>
		</section>

		<div class="album py-5 bg-light">
			<div class="container">

				<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3 g-3">
					<div class="col intro-step4">
						<div class="card shadow-sm">
							<img src="https://picsum.photos/199" alt="">

							<div class="card-body">
								<p class="card-text">This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.</p>
								<div class="d-flex justify-content-between align-items-center">
									<div class="btn-group">
										<button type="button" class="btn btn-sm btn-outline-secondary intro-step5">View</button>
										<button type="button" class="btn btn-sm btn-outline-secondary">Edit</button>
									</div>
									<small class="text-muted">9 mins</small>
								</div>
							</div>
						</div>
					</div>
                    <div class="col">
						<div class="card shadow-sm">
							<img src="https://picsum.photos/200" alt="">

							<div class="card-body">
								<p class="card-text">This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.</p>
								<div class="d-flex justify-content-between align-items-center">
									<div class="btn-group">
										<button type="button" class="btn btn-sm btn-outline-secondary">View</button>
										<button type="button" class="btn btn-sm btn-outline-secondary">Edit</button>
									</div>
									<small class="text-muted">9 mins</small>
								</div>
							</div>
						</div>
					</div>
                    <div class="col">
						<div class="card shadow-sm">
							<img src="https://picsum.photos/201" alt="">

							<div class="card-body">
								<p class="card-text">This is a wider card with supporting text below as a natural lead-in to additional content. This content is a little bit longer.</p>
								<div class="d-flex justify-content-between align-items-center">
									<div class="btn-group">
										<button type="button" class="btn btn-sm btn-outline-secondary">View</button>
										<button type="button" class="btn btn-sm btn-outline-secondary">Edit</button>
									</div>
									<small class="text-muted">9 mins</small>
								</div>
							</div>
						</div>
					</div>
				</div>
			</div>
		</div>

	</main>
{% endblock %}
  • Et vérifions le résultat :

Onboarding que lors de la première connexion

  • Gérons la première connexion en ajoutant un champ à notre entité User :
symfony console make:entity User

 Your entity already exists! So let's add some new fields!

 New property name (press <return> to stop adding fields):
 > isFisrtConnexion 

 Field type (enter ? to see all types) [boolean]:
 > boolean

 Can this field be null in the database (nullable) (yes/no) [no]:
 > yes

symfony console make:migration
symfony console d:m:m
  • Refactorons l’import du CSS et du JS d’intro.js en le supprimant de templates/base.html.twig et en l’ajoutant dans templates/home/index.html.twig :

De cette manière Intro.js ne sera chargé que lorsque cela sera nécessaire.

{% block body %}
	...
	{% block javascripts %}
		{{ parent() }}
		{% if app.user.isFirstConnexion %}
			{{ encore_entry_script_tags('intro') }}
		{% endif %}
	{% endblock %}
	{% block stylesheets %}
		{{ parent() }}
		{{ encore_entry_link_tags('intro') }}
	{% endblock %}
	...
{% endblock %}
  • Et enfin gérons le champ isFirstConnexion lors de l’ouverture de session en adaptant le fichier src/Security/AppAuthenticator.php
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        $user = $token->getUser();
        
        if (true === $user->getIsFirstConnexion()) {
            $user->setIsFirstConnexion(false);
            $this->em->persist($user);
            $this->em->flush();
        }

        if (null === $user->getIsFirstConnexion()) {
            $user->setIsFirstConnexion(true);
            $this->em->persist($user);
            $this->em->flush();
        }

        if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
            return new RedirectResponse($targetPath);
        };

        return new RedirectResponse($this->urlGenerator->generate('app_home'));
    }

Code source