Leny Bernard
Co-Fondateur et développeur chez Troopers, membre du comité technique du Web2Day, co-créateur du CMS Open Source Victoire et passionné de l'innovation et de de l'open source.
Il existe bon nombre de CMS basés ou connectables avec un projet Symfony mais il existe un genre relativement récent de CMS dont l’approche as a service promet rapidité, productivité, ergonomie, scalabilité, inter-opérabilité et la parallélisation des canaux de communication.
La philosophie de base des CMS headless est de décentraliser la gestion du contenu afin d’être capable de consommer celui-ci via les différents canaux qui peuvent en avoir besoin. On se rapproche ainsi du principe de séparation des responsabilités, en séparant le contenu, qui relève de la responsabilité du marketing, de sa représentation, responsabilité des designers et développeurs.
Contentful est probablement le cms headless le plus cher du marché mais c'est également d'après moi le plus complet en cette fin d'année 2017. D'ailleurs, hasard du calendrier je vous le jure, ils viennent d'annoncer une nouvelle levée de fonds de 28M$, voilà de quoi distancer encore plus les concurrents.
Pour cette raison, j'ai décidé de vous le présenter aujourd'hui néanmoins, si vous voulez continuer le voyage après cet article, je vous conseille d'aller voir du côté de Directus, de Prismic, Cockpit, GraphCMS que j'ai découvert dernièrement et allez voir l'annuaire des CMS Headless : https://headlesscms.org.
Alors c'est parti, je vous emmène avec moi, on va refondre le site de l’AFSY afin de lui offrir un système puissant de gestion de contenu (ux, versionnement, relecture, publication, rss, édition simultanée/collaborative des éditeurs...).
A tout moment, vous pourrez aller voir le dépôt compagnon sur lequel j'ai fait mes commits en préparant cet article ! De plus, vous pouvez voir ce que ça donne ici https://afsy.troopers.agency !
Tout d’abord, définissons brièvement les fonctionnalités attendues:
Pour se lancer, je vais partir d’un projet vide mais vous pouvez partir d’un projet existant tant qu’il est en 2.7+. En dessous, il sera difficile d’utiliser le bundle aidant à la connexion avec le cms.
symfony new afsy 3.4
et on va enchainer directement sur l'installation du bundle officiel :
composer require contentful/contentful-bundle
avec la déclaration du bundle dans le Kernel :
class AppKernel extends Kernel
{
public function registerBundles()
{
$bundles = [
//...
new Contentful\ContentfulBundle\ContentfulBundle(),
];
}
}
Assurez-vous qu'au moins un moteur de template est défini dans la configuration framework.templating
pour permettre au bundle de fonctionner :
#config.yml
framework:
templating: { engines: ['twig'] }
Désormais, destination https://contentful.com ! On va créer un compte si ce n'est pas déjà fait et ensuite on va créer un espace. Dans le jargon de Contentful, un espace se résumera très souvent à un projet mais il est tout à fait possible d'imaginer d'autres organisations. La notre se nommera afsy et partira sur un projet vide :
Le champ language
est là pour définir la langue par défaut des contenus qui vont être créés, il sera possible d'en ajouter par la suite mais comme c'est pour l'AFSY, on va choisir Francais 🇫🇷 .
La deuxième étape est alors de définir le modèle de notre contenu, on y est d'ailleurs invité dès le début :
On va commencer tranquille avec le type Page
qui permettra de faire une première intégration avec notre projet Symfony de manière facile et rapide :
Je vous conseille de jouer un peu avec le système, pour ma part, j'ai trouvé l'interface très bien faite et adaptée à un profil technique comme le notre mais je vous laisse vous faire votre propre avis :)
Ensuite, si vous n'êtes pas allergiques à npm
, je vous conseille d'installer le paquet contentful-import
qui va vous permettre d'importer facilement schémas, contenus et assets, c'est idéal pour potentiellement capitaliser entre des projets ou dans le cadre de tests, cela fait d'excellentes données de test (fixtures) :
npm install -g contentful-import
ℹ pour vous générer les fichiers json, j'ai utilisé son collègue contentful-export
Une fois que c'est fait, vous pouvez alors télécharger le fichier page.json pour l'importer dans votre space (ou dans un nouvel espace, vous faites bien comme c'est le plus pratique pour vous cher ami) :
contentful-import --space-id YOUR_SPACE_ID --management-token YOUR-MGTOKEN --content-file page.json
ℹ Pour récupérer votre space-id ainsi que votre delivery_token (qu'il vous faudra dans quelques minutes), ça se passe dans le menu API > Content delivery / preview tokens > Website key
ℹ Pour récupérer/générer votre management-token, ça se passe dans le menu API > Content management tokens
Allez voir dans la partie Content
, vous devriez avoir la page d'accueil, cela suffira pour commencer à jouer avec cette partie de notre cms.
Pour faire le lien avec votre espace contenful, il faut définir 2 choses relatives à configuration de Contentful: le spaceId
et le token
.
#parameters.yml.dist
parameters:
contentful_delivery_space: spaceID
contentful_delivery_token: token
#config.yml
contentful:
delivery:
space: '%contentful_delivery_space%'
token: '%contentful_delivery_token%'
Pour afficher cette belle page dans notre site, on va avoir besoin d'un contrôleur PageController
avec une action showAction
qui sera chargée d'aller chercher dans l'api de Contentful la page relative au slug passé en request
(homepage par défaut) et d'afficher son contenu. Avant de faire la vue et l'action, on va installer un bundle nous permettant de convertir du markdown en html pour pouvoir ensuite l'interpréter, le gros classique est le KnpMarkdownBundle donc :
composer require knplabs/knp-markdown-bundle
et puis l'immanquable ajout dans l'AppKernel (on tient bon, Flex sera bientôt partout ✊) :
//src/AppKernel.php
$bundles = [
//...
new Knp\Bundle\MarkdownBundle\KnpMarkdownBundle(),
];
Allons-y pour le controller et l'action showAction
:
<?php
//src/AppBundle/Controller/CMS/PageController.php
namespace AppBundle\Controller\CMS;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class PageController extends Controller
{
/**
* @Route("/", name="homepage")
* @Route("/{slug}", name="app_cms_page_show")
*/
public function showAction($slug = 'homepage')
{
$client = $this->get('contentful.delivery');
$query = new \Contentful\Delivery\Query;
$query->setContentType('page')
->where('fields.slug', $slug)
->setLimit(1);
$entry = $client->getEntries($query)[0];
if (!$entry) {
throw new NotFoundHttpException;
}
return $this->render('cms/page/show.html.twig', [
'page' => $entry,
]);
}
}
et voici une vue qui va faire honneur à notre contenu:
{# app/Resources/views/cms/page/show.html.twig #}
{% extends "::base.html.twig" %}
{% block body %}
<h1>{{ page.getName() }}</h1>
{{ page.getText()|markdown|raw }}
{% endblock %}
Si on va sur la page d'accueil... 🎊 ça communique avec Contentful et les modifications se répercuteront bien sûr dans le site lorsqu'on décidera de les publier (avec quelques petits secondes de délais, cache oblige) !
C'est un peu minimaliste comme page d'accueil mais on ne va pas en rester là ; je veux y afficher une liste d'événements et j'ai envie d'avoir un design et un template spécifique pour ma page d'accueil et ce n'est pas en markdown qu'on va le faire, rassurez-vous !
Je vous laisse créer une autre page, par exemple la page a-propos
pour tester que la route app_cms_page_show
fonctionne bien sûr les autres pages (que la page homepage).
Pour rendre la page d'accueil attractive, on m'a demandé d'avoir 3 parties :
une cover permettant de mettre en avant le logo, la base ligne de l'asso et le bouton d'inscription au google group
les 3 objectifs de l'asso:
3. la liste des événements passés ou à venir
2 réflexions :
- en l'état, ma page d'accueil a la même structure que ma page a-propos
, je vais avoir besoin de flexibilité, il faut réussir à le faire sans trop complexifier le mécanisme
- il peut être tentant de mettre les objectifs en dur dans le twig car c'est pas grand chose à changer et ça ne prend pas longtemps si besoin... ma philosophie est que si c'est du contenu, c'est le gestionnaire de contenu qui en est le responsable et sa place est donc dans le cms (et créer un nouveau modèle est fun, prend 3 minutes et fait gagner du temps le jour où on veut les changer).
Pour créer le modèle Goal et ajouter le contenu (3 objectifs + 3 images), 2 possibilités pour vous :
cli
et ce fichier :contentful-import --space-id SPACE_ID --management-token MGT_TOKEN --content-file goals.json
Une fois que c'est fait, on va faire en sorte de les afficher dans la page d'accueil et pour commencer je vous propose cette petite astuce qui vous permettra de customiser les vues de certaines pages spéciales comme la page d'accueil tout en gardant une vue par défaut pour les pages dites classiques :
<?php
use Symfony\Component\Templating\EngineInterface;
class PageController extends Controller
{
/**
* @Route("/", name="homepage")
* @Route("/{slug}", name="page_show")
*/
public function showAction(EngineInterface $twigEngine, $slug = 'homepage')
{
//...
//seek for custom template
$template = sprintf('cms/page/custom/%s.html.twig', $slug);
if (!$twigEngine->exists($template) ) {
$template = 'cms/page/show.html.twig';
}
// replace this example code with whatever you need
return $this->render($template, [
'page' => $entry,
]);
}
}
ℹ ce petit bout de code très simple va permettre d'aller d'abord voir s'il n'existe pas une template spécial pour la page qu'on essaye de charger (app/Resources/views/cms/page/custom/homepage.html.twig
) et va revenir sinon sur la vue par défaut (app/Resources/views/cms/page/show.html.twig
)
On va donc pouvoir commencer à personnaliser la page d'accueil :
{% extends "::base.html.twig" %}
{% block body %}
<section id="cover-section">
{{ page.getText()|markdown|raw }} {# here will stand the name, baseline and call to action #}
</section>
<section id="goals-section">
<h3>
Nos objectifs
</h3>
<!-- Add goals section here -->
</section>
<section id="events-section">
<h3>
Les événements
</h3>
<!-- Add events section here -->
</section>
{% endblock %}
On voit ici qu'on a maintenant 3 sections (dont 2 vides) :
Pour les objectifs et les événements, on peut utiliser la fonction render()
(ou render_esi
):
{{ render(path('app_cms_event_rendergoals')) }}
ℹ Plus d'informations sur la fonction render
/ render_esi
et voici les actions de contrôleurs associés :
<?php
namespace AppBundle\Controller\CMS;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Component\HttpFoundation\Response;
/**
* @Route("/goals")
*/
class GoalsController extends Controller
{
/**
* @Route("/")
*/
public function renderListAction()
{
$client = $this->get('contentful.delivery');
$query = new \Contentful\Delivery\Query;
$query->setContentType('goal');
$response = '';
foreach ($client->getEntries($query) as $goal) {
$response.= $this->renderView('cms/goals/_item.html.twig', [
'goal' => $goal
]);
}
return new Response($response);
}
}
et la vue d'un objectif :
{# app/Resources/views/cms/goals/_item.html.twig #}
<h3>{{ goal.getName() }}</h3>
{% if goal.getPicture() %}
<img src="{{ goal.getPicture().file.url ~ "?fm=jpg&w=350&h=350" }}"/>
{% endif %}
{{ goal.getDescription()|markdown|raw }}
Pour les événements, on peut s'y prendre exactement de la même manière avec la création d'un modèle de données côté Contentful et avec l'utilisation de la fonction render...
Voici le fichier pour définir le modèle Event comme je l'ai fait, il y a deux événéments inclus : events.json.
Voici le controller EventController
:
<?php
namespace AppBundle\Controller\CMS;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Component\HttpFoundation\Response;
/**
* @Route("/event")
*/
class EventController extends Controller
{
/**
* @Route("/")
*/
public function renderListAction($max = 10)
{
$client = $this->get('contentful.delivery');
$query = new \Contentful\Delivery\Query;
$query->setContentType('event')
->orderBy('fields.date', true)
->setLimit($max);
$response = '';
foreach ($client->getEntries($query) as $event) {
$response.= $this->renderView('cms/event/_item.html.twig', [
'event' => $event
]);
}
return new Response($response);
}
}
et la vue associée :
<section>
<div id="{{ event.getId() }}-map" style="width: 300px; height: 300px;"></div>
<div>
{% if event.getDate() < date('now') %}
<span style="right: 20px; top: 20px; position: absolute;">Évenement passé</span>
{% endif %}
<div>
<h4>{{ event.getTitle() }}</h4>
<small>{{ event.getDate()|date('d/m/Y') }}</small>
{% for tag in event.getTags() %}
<strong>{{ tag }}</strong>
{% endfor %}
{{ event.getDescription() }}
<a href="{{ event.getLink() }}" class="mdl-button">Plus d'info</a>
</div>
</div>
</section>
<script>
function r(f){/in/.test(document.readyState)?setTimeout('r('+f+')',9):f()}
r(function(){
initMap{{ event.getId() }}();
});
function initMap{{ event.getId() }}() {
var eventLocation{{ event.getId() }} = {lat: {{ event.getLocation().latitude }}, lng: {{ event.getLocation().longitude }} };
var map{{ event.getId() }} = new google.maps.Map(document.getElementById('{{ event.getId() }}-map'), {
zoom: 13,
center: eventLocation{{ event.getId() }},
disableDefaultUI: true
});
var marker{{ event.getId() }} = new google.maps.Marker({
position: eventLocation{{ event.getId() }},
map: map{{ event.getId() }}
});
}
</script>
Vous noterez qu'il y a des cartes gmap embarquées donc il faut rajouter la lib dans le layout de base :
{# app/Resources/views/base.html.twig #}
{% block javascripts %}
<script async defer src="https://maps.googleapis.com/maps/api/js?key={{ googleMapAPIKEY }}{% block gmapExtraAttributes %}{% endblock %}"></script>
{% endblock %}
et bien-sur ajoutez en parameter la clé (que vous aurez générée ici) et passez-là en global :
#parameters.yml.dist
parameters:
googleMapsApiKey: key
#config.yml
twig:
#...
globals:
googleMapAPIKEY: '%googleMapsApiKey%'
Créer un blog simple dans un site Symfony n'est pas très compliqué, mais Contentful va nous nous permettre d’éviter de réinventer la roue tout en solidifiant et professionnalisant notre blog.
Lorsqu'on créé un nouvel espace dans Contentful, plutôt que de partir avec le squelette vide, on peut choisir le template Blog (ainsi que catalogue de produit et galerie photo) qui nous amènera un modèle éprouvé, des données de test ainsi que des exemples de consommations dans beaucoup de langages. D'ailleurs, si vous voulez voir d'autres implémentations, vous pouvez aller voir les projets bac à sable : un catalogue de produit avec Symfony ou un blog avec Laravel.
💾 Voici le fichier qu'il vous faudra importer comme précédemment avec contentful-import pour récupérer dans votre espace un blog prêt à utiliser blog.json
Le blog apporté par Contentful est minimaliste et c'est tant mieux, pas de fioriture et libre à nous d'ajouter ce que l'on souhaite. De base, on a :
On va donc faire une vue Blog qui va lister tous les articles puis une vue pour afficher chaque article et ouvrir un système de commentaires sans effort. On ajoutera aussi une vue pour lister uniquement les articles d'une catégorie.
Tout d'abord, on va commencer par créer le PostController
comme ceci :
<?php
namespace AppBundle\Controller\CMS;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
class PostController extends Controller
{
const CONTENT_TYPE_POST = '2wKn6yEnZewu2SCCkus4as';
const CONTENT_TYPE_CATEGORY = '5KMiN6YPvi42icqAUQMCQe';
const CONTENT_TYPE_AUTHOR = '1kUEViTN4EmGiEaaeC6ouY';
}
ℹ Notez la définition des constantes CONTENT_TYPE_*
. Le système génère par défaut un identifiant unique lorsqu'on créé un Modèle (content-type
), afin d’éviter d'avoir un conflit lors d'un import avec un modèle ayant le même identifiant. Si vous êtes sûrs de ne pas générer de conflit, il est possible de définir un identifiant non obscurci comme dans l'exemple précédent sur le modèle Page
qui a page
comme identifiant.
On va rajouter ensuite la méthode permettant de lister les articles par ordre antéchronologique :
/**
* @Route("/blog")
*/
public function indexAction()
{
$client = $this->get('contentful.delivery');
$query = new \Contentful\Delivery\Query;
$query->setContentType(self::CONTENT_TYPE_POST)->orderBy('fields.date', true);
return $this->render('cms/post/index.html.twig', [
'entries' => $client->getEntries($query)
]);
}
et la vue associée :
{# cms/post/index.html.twig #}
{% extends "base.html.twig" %}
{% block title %}Blog - {{ parent() }}{% endblock %}
{% block body %}
<section id="section-blog">>
<h1>{{ title|default('Blog') }}</h1>
</section>
<main>
{% for post in entries %}
<section>
<h2>{{ post.title }}
<small>par
{% for post.getAuthor() %}
{{ post.author.name }}
{% if loop.index == post.getAuthor()|length - 1 %}
et
{% else if not loop.last %}
,
{% endif %}
{% endfor %}
</small> le {{ post.getDate()|date('d/m/Y') }}
</h2>
<img src="{{ post.getFeaturedImage().file.url ~ "?fm=jpg&w=215&h=215" }})"/>
{% for category in post.getCategory() %}
<strong>
{{ category.getTitle() }}
</strong>
{% endfor %}
{{ post.getBody()|markdown|striptags|truncate(150)|raw }}
</section>
{% endfor %}
</main>
{% endblock %}
Si on se rend sur /blog, on va désormais avoir l'affichage certes rudimentaire mais non moins opérationnel des articles de notre blog. Pour l'instant, il manque l'action de visualisation d'un article, on veut lire le contenu complet, pas juste l'extrait.
Implémentons tout ça :
//src/AppBundle/Controller/CMS/PostController.php
/**
* @Route("/blog/{slug}")
*/
public function showAction($slug)
{
$client = $this->get('contentful.delivery');
$query = new \Contentful\Delivery\Query;
$query->setContentType(self::CONTENT_TYPE_POST)
->where('fields.slug', $slug, 'match')
->setLimit(1);
return $this->render('cms/post/show.html.twig', [
'post' => $client->getEntries($query)[0]
]);
}
et la vue associée :
{# app/Resources/views/cms/post/show.html.twig #}
{% extends "::base.html.twig" %}
{% block title %}{{ post.getTitle() }} - {{ parent() }}{% endblock %}
{% block body %}
<h1>{{ post.getTitle() }}</h1>
<ul>
{% for category in post.getCategory() %}
<li>
{{ category.getTitle() }}
</li>
{% endfor %}
</ul>
{{ post.getBody()|markdown|raw }}
{% endblock %}
Ne pas oublier de rajouter un petit lien dans l'index du blog pour pouvoir naviguer sur notre article :
{# app/Resources/views/cms/post/index.html.twig #}
...
{% block body %}
...
{% for post in entries %}
...
<a href="{{ path('app_cms_post_show', {slug: post.getSlug()}) }}">
Lire
</a>
{% endfor %}
...
{% endblock %}
...
Attaquons-nous aux catégories désormais. La liste des articles d'une catégorie n'est finalement qu'une liste d'articles filtrée sur la catégorie, on va donc avoir besoin d'ajouter un action et une route particulière pour faire cette action mais on va pouvoir utiliser la même vue que l'index général :
//src/AppBundle/Controller/CMS/PostController
/**
* @Route("/blog/category/{slug}")
*/
public function listByCategoryAction($slug, EngineInterface $twigEngine)
{
$client = $this->get('contentful.delivery');
//find first the category
$query = new \Contentful\Delivery\Query;
$query->setContentType(self::CONTENT_TYPE_CATEGORY)
->where('fields.slug', $slug)
->setLimit(1);
$category = $client->getEntries($query)[0];
//find posts by category
$query = new \Contentful\Delivery\Query;
$query->setContentType(self::CONTENT_TYPE_POST)
->where('fields.category.sys.id', $category->getId())
->orderBy('fields.date');
//seek for category custom template
$template = sprintf('cms/category/custom/%s.html.twig', $slug);
if (!$twigEngine->exists($template) ) {
$template = 'cms/post/index.html.twig';
}
return $this->render($template, [
'title' => $category->getTitle(),
'entries' => $client->getEntries($query)
]);
}
En dehors du code du client Contentful qui nous est maintenant presque familier, on voit qu'on fait 2 requêtes. Une première pour aller chercher la catégorie en fonction du slug, une deuxième pour aller chercher les articles associés à cette catégorie.
Comme vous l'avez peut-être remarqué j'ai utilisé la même mécanique que sur la homepage pour avoir le droit de surcharger le template de la page d'une collection. Ça nous sera utile par exemple pour mettre en forme le calendrier de l'avent qui nous est demandé, qui est une catégorie du côté du cms.
On est libre de faire des choses évoluées très rapidement. Par exemple, pour ce besoin très précis de calendrier de l'avent, je me suis amusé à faire une petite veille et j'ai trouvé chez les amis de Codrops une expérimentation Cubes Advent Calendar qui correspondait tout à fait à mes attentes :
C'est un peu long et pas si fou que ça donc je préfère détailler d'autres points mais si ca vous intéresse, retrouvez l'implémentation du calendrier de l'avent ici.
Jusqu'à présent, on est resté dans une démarche de consommation du contenu situé dans le cms mais pour ce nouveau besoin, c'est un internaute qui doit créer, en passant par le site, un nouvel événement. Cet événement devra avoir un statut particulier "En attente" et ne s'affichera sur le site que lorsqu'il aura été validé.
Pour ce faire, on va passer par la Content Management API (contrairement à précédemment où nous utilisions la Content Delivery API). D'ailleurs, vous l'avez déjà utilisé sans forcément vous en rendre compte car c'est l'API qu'utilise le script contentful-import
pour ajouter du contenu dans l'espace.
C'est là que ça se gâte (un tout petit peu)
Et oui, hélas au jour où j'écris cet article, il semble qu'on ait quelques semaines d'avance car le bundle attend la version stable de la lib contentful/contentful-management
pour y implémenter les fonctions visant à faciliter la communication avec l'API de management.
En fait, c'est pas si grave car la solution existe ! Donc avant de faire une PR ou au lieu d'attendre une mise à jour du bundle, on peut déjà bricoler quelque chose de propre pour avoir accès à un petit service de Management
qui nous permettra de créer nos événements en un claquement de doigts ou presque.
Tout commence avec composer, on va installer contentful-management (il n'y a pas de version stable encore) :
composer require contentful/contentful-management:@dev
Ensuite, en suivant la documentation, on comprend qu'il va falloir instancier le client Contentful\Management\Client
en lui passant le content_management_api_key
et le space_id
:
use Contentful\Management\Client;
use Contentful\Management\Resource\Entry;
$client = new Client('content_management_api_key', 'space_id');
$entry = new Entry('content_type_id');
$entry->setField('title', 'en-US', 'Entry title');
$client->entry->create($entry);
On va créer un service pour s'affranchir de cette instanciation:
#parameters.yml
parameters:
contentful_management_token: token
#app/config/services.yml
services:
Contentful\Management\Client:
arguments:
$token: '%contentful_management_token%'
$currentSpaceId: '%contentful_delivery_space%'
Le client va nous permettre de créer notre nouvel évenement.
Voici maintenant les quelques étapes pour développer cette fonctionnalité :
On va créer le modèle Event qui sera un miroir du content-type défini dans Contentful :
<?php
namespace AppBundle\Domain\Model;
use Symfony\Component\Validator\Constraints as Assert;
class Event
{
/**
* @var string
*/
private $title;
/**
* @var \DateTime
*/
private $date;
/**
* @var float
*/
private $latitude;
/**
* @var float
*/
private $longitude;
/**
* @var string
*/
private $description;
/**
* @var array
*/
private $tags = [];
/**
* @var string
* @Assert\Url()
*/
private $link;
/**
* @return string
*/
public function getTitle(): ?string
{
return $this->title;
}
/**
* @param string $title
* @return Event
*/
public function setTitle(string $title): Event
{
$this->title = $title;
return $this;
}
/**
* @return \DateTime
*/
public function getDate(): ?\DateTime
{
return $this->date;
}
/**
* @param \DateTime $date
* @return Event
*/
public function setDate(\DateTime $date): Event
{
$this->date = $date;
return $this;
}
/**
* @return string
*/
public function getDescription(): ?string
{
return $this->description;
}
/**
* @param string $description
* @return Event
*/
public function setDescription(string $description): Event
{
$this->description = $description;
return $this;
}
/**
* @return array
*/
public function getTags(): ?array
{
return $this->tags;
}
/**
* @param array $tags
* @return Event
*/
public function setTags(array $tags): Event
{
$this->tags = $tags;
return $this;
}
/**
* @return string
*/
public function getLink(): ?string
{
return $this->link;
}
/**
* @param string $link
* @return Event
*/
public function setLink(string $link): Event
{
$this->link = $link;
return $this;
}
/**
* @param string $latitude
* @return Event
*/
public function setLatitude(string $latitude): Event
{
$this->latitude = $latitude;
return $this;
}
/**
* @return float
*/
public function getLatitude(): ?float
{
return $this->latitude;
}
/**
* @param float $longitude
* @return Event
*/
public function setLongitude(float $longitude): Event
{
$this->longitude = $longitude;
return $this;
}
/**
* @return float
*/
public function getLongitude(): ?float
{
return $this->longitude;
}
}
Puis on va faire la formulaire EventType
:
<?php
namespace AppBundle\Form;
use AppBundle\Domain\Model\Event;
use Contentful\Location;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class EventType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('title')
->add('date', DateTimeType::class, [
'html5' => true,
'years' => range(date('Y'), date('Y') + 5)
])
->add('location', LocationType::class, [
'inherit_data' => true,
])
->add('description', TextareaType::class)
->add('tags', TextType::class, [
'attr' => [
'placeholder' => 'SFPot, Nantes, Pizza'
]
])
->add('link')
;
$builder->get('tags')
->addModelTransformer(new CallbackTransformer(
function ($tagsAsArray) {
return implode(', ', $tagsAsArray);
},
function ($tagsAsString) {
return explode(', ', $tagsAsString);
}
));
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefault('data_class', Event::class);
}
}
ainsi que le LocationType
:
<?php
namespace AppBundle\Form;
use Contentful\Location;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class LocationType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('address', TextType::class, [
'mapped' => false,
])
->add('latitude', HiddenType::class)
->add('longitude', HiddenType::class)
;
}
}
On va ensuite ajouter l'action d'ajout dans le controller EventController:
/**
* @Route("/new")
* @Method(methods={"GET", "POST"})
* @param Request $request
* @return Response
*/
public function newAction(Request $request)
{
$event = new Event();
$form = $this->createForm(EventType::class, $event);
if ($request->isMethod(Request::METHOD_POST)) {
$form->handleRequest($request);
if ($form->isValid()) {
//@todo Do something
}
}
return $this->render('cms/event/new.html.twig', [
'form' => $form->createView()
]);
}
et la vue associée :
{# app/Resources/views/cms/event/new.html.twig #}
{% extends "::base.html.twig" %}
{% block body %}
<h1>Proposez un événement</h1>
{{ form_start(form) }}
{{ form_row(form.title) }}
{{ form_row(form.date) }}
{{ form_widget(form.location) }}
<div id="previewMap" style="width: 300px; height: 200px;"></div>
{{ form_rest(form) }}
<input type="submit"/>
{{ form_end(form) }}
{% endblock %}
{% block gmapExtraAttributes %}&libraries=places&callback=initMap{% endblock %}
{% block javascripts %}
{{ parent() }}
<script>
function initMap() {
var map = new google.maps.Map(document.getElementById('previewMap'), {
center: {lat: 47.212205, lng: -1.550555},
zoom: 13
});
var input = /** @type {!HTMLInputElement} */(
document.getElementById('event_location_address'));
var latInput = /** @type {!HTMLInputElement} */(
document.getElementById('event_location_latitude'));
var longInput = /** @type {!HTMLInputElement} */(
document.getElementById('event_location_longitude'));
map.controls[google.maps.ControlPosition.TOP_LEFT].push(input);
var autocomplete = new google.maps.places.Autocomplete(input);
autocomplete.bindTo('bounds', map);
var infowindow = new google.maps.InfoWindow();
var marker = new google.maps.Marker({
map: map,
anchorPoint: new google.maps.Point(0, -29)
});
autocomplete.addListener('place_changed', function() {
infowindow.close();
var place = autocomplete.getPlace();
if (!place.geometry) {
// User entered the name of a Place that was not suggested and
// pressed the Enter key, or the Place Details request failed.
window.alert("No details available for input: '" + place.name + "'");
return;
}
latInput.value = place.geometry.location.lat();
longInput.value = place.geometry.location.lng();
// If the place has a geometry, then present it on a map.
if (place.geometry.viewport) {
map.fitBounds(place.geometry.viewport);
} else {
map.setCenter(place.geometry.location);
map.setZoom(17); // Why 17? Because it looks good.
}
var address = '';
if (place.address_components) {
address = [
(place.address_components[0] && place.address_components[0].short_name || ''),
(place.address_components[1] && place.address_components[1].short_name || ''),
(place.address_components[2] && place.address_components[2].short_name || '')
].join(' ');
}
infowindow.setContent('<div><strong>' + place.name + '</strong><br>' + address);
infowindow.open(map, marker);
});
}
</script>
{% endblock %}
Pour traiter le formulaire et ne pas outrepasser la responsabilité du controller, on va créer un service responsable de créer un événement à partir d'un formulaire: AddEventHandler.
Ce service utilisera un DataTransformer qu'il faudra aussi créer pour transformer l'Événement en Entry et utilisera ensuite le service Contentful\Management\Client pour envoyer l'Entry à Contentful:
<?php
namespace AppBundle\Domain\Transformer;
use AppBundle\Domain\Model\Event;
use Contentful\Management\Resource\Entry;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\HttpFoundation\RequestStack;
class EventToEntryTransformer implements DataTransformerInterface
{
/**
* @var RequestStack
*/
private $requestStack;
/**
* EventToEntryTransformer constructor.
* @param RequestStack $requestStack
*/
public function __construct(RequestStack $requestStack)
{
$this->requestStack = $requestStack;
}
/**
* @param Event $event
* @return Entry
*/
public function transform($event)
{
$locale = $this->requestStack->getCurrentRequest()->getLocale();
$entry = new Entry('event');
$entry->setField('title', $locale, $event->getTitle());
$entry->setField('date', $locale, $event->getDate()->format('c'));
$entry->setField('location', $locale, [
"lat" => $event->getLatitude(),
"lon" => $event->getLongitude()
]);
$entry->setField('description', $locale, $event->getDescription());
$entry->setField('tags', $locale, $event->getTags());
$entry->setField('link', $locale, $event->getLink());
$entry->setField('slug', $locale, uniqid('event_', true));
return $entry;
}
/**
* @param Entry $entry
* @return Event
*/
public function reverseTransform($entry)
{
$locale = $this->requestStack->getCurrentRequest()->getLocale();
$event = new Event();
$event->setTitle($entry->getField('title'), $locale);
$event->setDate($entry->getField('date'), $locale);
$event->setLatitude($entry->getField('location')->getField('latitude'), $locale);
$event->setLongitude($entry->getField('location')->getField('longitude'), $locale);
$event->setDescription($entry->getField('description'), $locale);
$event->setTags($entry->getField('tags'), $locale);
$event->setLink($entry->getField('link'), $locale);
return $event;
}
}
ℹ https://www.contentful.com/developers/docs/concepts/data-model/
<?php
namespace AppBundle\Domain\Handler;
use AppBundle\Domain\Model\Event;
use AppBundle\Domain\Transformer\EventToEntryTransformer;
use Contentful\Management\Client;
class AddEventHandler
{
/**
* @var Client
*/
private $client;
/**
* @var EventToEntryTransformer
*/
private $transformer;
public function __construct(Client $client, EventToEntryTransformer $transformer)
{
$this->client = $client;
$this->transformer = $transformer;
}
public function handle(Event $event) {
$entry = $this->transformer->transform($event);
$this->client->entry->create($entry);
}
}
Dernière chose à faire, ajouter l'appel au handler dans le Controller pour envoyer l'évenement dans le CMS :
<?php
namespace AppBundle\Controller;
use AppBundle\Domain\Handler\AddEventHandler;
//...
/**
* @Route("/new")
* @Method(methods={"GET", "POST"})
* @param Request $request
* @param AddEventHandler $addEventHandler
*
* @return Response
*/
public function newAction(Request $request, AddEventHandler $addEventHandler)
{
$event = new Event();
$form = $this->createForm(EventType::class, $event);
if ($request->isMethod(Request::METHOD_POST)) {
$form->handleRequest($request);
if ($form->isValid()) {
$addEventHandler->handle($event);
}
}
return $this->render('cms/event/new.html.twig', [
'form' => $form->createView()
]);
}
Désormais, si on se rend sur /event/new
, on a accès au formulaire de dépôt d'un événement et lorsqu'on le soumet, un événement est ajouté dans Contentful en brouillon.
Il ne reste plus qu'à le valider. On peut imaginer que le AddEventHandler pourrait s'occuper de notifier la room Event dans le Slack de l'AFSY afin d'avoir une bonne réactivité mais c'est hors sujet ;)
Nous ne sommes qu'au début des CMS Headless mais on sent déjà qu'il s'agit d'une solution plus adaptée au développement applicatif moderne.
Fonctionnel mais il semble être encore un peu jeune. L'utilisation du client n'est pas des plus élégante et même si le sdk fait un travail conséquent notamment avec l'objet DynamicEntry, il faut l'abstraire dans des services métiers, spécialisés dans la récupération et la préparation du contenu pour ne pas surcharger les Controllers. De plus, le bundle vient avec un Collector pour la WDT:
C'est plutôt sympa pour garder un oeil sur le nombre de requêtes effectuées pour chaque page.
Et puis comme expliqué dans l'article, des évolutions devraient arriver dans les semaines à venir pour faciliter encore l'intégration des models avec Contentful.
Cela ne vous aura pas échappé, en dehors de l'édition Developper, Contentful n'est pas donné, loin de là :
Cependant, même l'offre Developper peut fonctionner pour des petits sites car le sdk PHP offre un système de cache permettant d'éviter les appels trop fréquents en prod : https://www.contentful.com/developers/docs/php/tutorials/caching-in-the-php-cda-sdk/
La réponse apportée par la core team du sdk php est d'utiliser, comme eux, la librairie PHP-VCR qui permet d'enregistrer les appels API et de les enregistrer sur des cassettes (oui oui sérieux) afin de les rejouer dans les tests futurs en simulant les appels à l'API contentful.
Plus d'info ici Mock Client Call #170.
Allez sur ce, je vous souhaite de belles fêtes de fin d'année pleines de contenu (content full hum...) et je souhaite une bonne fête à tous les Nicolas :).