Comment développer une extension Contao ? (1ère partie)

par Julien

Photo d'un écran de développeur
Photo source par Pexels

Aujourd’hui, c’est un jour pour les développeurs ! Sortez vos IDEs, échauffez-vous les doigts, on va apprendre à développer une extension Contao.

Au programme : mis en place de la structure, du fichier Composer. Puis explications autour du fonctionnement du back-office et du front-office. Et pour finir, quelques trucs et astuces pour développer et maintenir votre extension.

Il est également important de préciser qu’il n’existe pas que cette façon de développer une extension ! Cet article ne représente qu’une façon de faire, améliorée et éprouvée au fil des années et des versions de Contao. Prenez ce que vous voulez et adaptez le reste ;)

Même si nous allons essayer de détailler un maximum de détails techniques dans cet article, il est fort probable que vous n’y paniez rien sans un minimum de connaissances en développement. Mais pas d’inquiétudes, la majorité des développeurs ne comprennent pas tout le temps ce qu’il se passe non plus !

Vous êtes toujours là ? Bon et bien c’est parti :D

Les pré-requis

Avant de commencer, il est essentiel d’avoir ces quelques notions :

  • Langage PHP
  • Principe et fonctionnement de Composer
  • Connaissance de GIT
  • Principes d’une structure MVC

Ainsi que ces outils :

  • Votre IDE préféré
    • J’utilise Sublime Text personnellement
  • Un système de versionning (GIT, SVN)
    • GIT, via Github ou Gitlab, selon l’extension
  • Un Contao opérationnel
    • Contao 4 (même si la structure est quasi identique à celle de Contao 3)
    • L’onglet backend (https://www.votresite.com/contao)
    • L’onglet install (https://www.votresite.com/contao/install)
    • Le Contao Manager (https://www.votresite.com/contao-manager.phar.php)

Il est également recommandé d’avoir le cookbook de Contao sous les yeux, notamment pour la partie backend.

La structure d'une extension Contao

Une extension comporte généralement les dossiers suivants :

Répertoire Description
assets Contient les ressources “publics” de l’extension, comme des fichiers css ou des images
config Contient la configuration de l’extension
dca Contient les tables de votre extension, qui formeront également le back-office
languages Contient les traductions de votre extension, représentés par des sous-dossiers selon la langue (en, fr…)
library Contient l’ensemble de votre structure MVC et des fichiers régissant le fonctionnement de l’extension, Ce dossier peut s’appeler également src, ça dépend des conventions.
On y trouve une sous structure qui ressemble généralement à :
  • Backend
  • Controller(s)
  • Model(s)
  • Module(s)
Le pluriel n’est qu’une question de convention également. J’ai une préférence pour le singulier partout personnellement.
templates Contient l’ensemble des vues de votre extension. Il peut être découpé en autant de sous-dossiers que vous voulez, puisque vous devrez ensuite programmer les routes.
Généralement, je découpe ce dossier en deux :
  • modules (les vues directes de mes modules)
  • elements (des blocs indépendants pouvant être appelés dans différents modules de l’extension)

Voilà, ça, c’est une structure typique chez Web ex Machina. Si vous êtes l’un de nos clients, il est plus que probable que votre site comporte des modules façonnés ainsi.

Il peut y avoir également des différences, comme notamment l’absence du dossier library/src mais des dossiers “classes”, “models”, “modules” à la racine de l’extension. C’est ce que fait le CMS Contao pour ces modules natifs.

Il n’y a aucun problème à ça, car les chargements de ces fichiers sont gérés via le fichier Composer. Tant que le système sait où charger les classes de votre extension, vous pouvez bien faire comme vous l’entendez !

Contao code également ses extensions comme l'exige les Bundles de Symfony. C'est pas notre cas car nous n'avons pas encore eu besoin de le faire ! Et c'est moins pratique pour apprendre. Cela explique toutefois la structure plus importante de l'extension news ci-dessous.

Structure d'une extension Web ex Machina
Structure d'une extension Web ex Machina
Structure d'un bundle Contao
Structure d'un bundle Contao

Le fichier Composer

Bon, c'est un peu la base quand on développe des extensions, on a généralement besoin d'un fichier Composer ! Il se place à la racine de votre extension et il a cette tronche là.

{
    "name": "webexmachina/contao-planning",
    "description": "Planning for Contao Open Source CMS",
    "keywords": ["planning", "schedule", "calendar", "webexmachina", "module", "contao"],
    "type": "contao-module",
    "license": "LGPL-3.0+",
    "authors": [
        {
            "name": "Web ex Machina",
            "homepage": "https://www.webexmachina.fr",
            "role":"Developer"
        }
    ],
    "support":{
        "website":"https://www.webexmachina.fr/",
        "issues":"https://github.com/webexmachina/contao-planning/issues",
        "source":"https://github.com/webexmachina/contao-planning"
    },
    "require": {
        "php": ">=5.5",
        "contao/core-bundle": "~3.2 || ~4.1",
        "contao-community-alliance/composer-plugin": "~2.4 || ~3.0",
        "terminal42/dcawizard": "^2.4",
        "terminal42/notification_center": "^1.4"
    },
    "autoload":{
        "psr-0": {
            "WEM\\": [
                "library/"
            ]
        },
        "classmap": [""]
    },
    "extra": {
        "contao": {
            "sources": {
                "": "system/modules/wem-contao-planning"
            },
            "runonce": [
                "system/modules/wem-contao-planning/config/runonce.php"
            ]
        }
    }
}

Require

Rien d’original si vous avez déjà eu à faire des fichiers composer. Pensez à bien préciser la version de Contao requise via l’instruction : "contao/core-bundle": "~3.x || ~4.x" où x représente la version minimale souhaitée.

Ajoutez ensuite que toutes les extensions dont vous avez besoin.

Attention toutefois à ne pas avoir trop de dépendances pour votre extension. Plus vous avez de librairies externes, plus votre application sera difficile à maintenir.
Depuis que Contao se place en tant que bundle de Symfony, la majorité des besoins basiques sont couverts.

Je trouve plus intéressant de chercher une façon de faire qui utilise le CMS plutôt que d’ajouter une librairie externe, spécifique, qui pourrait faire doublon.

Autoload

Via l’option de l’autoload, vous devez également préciser le chemin vers votre système de classes. Entre l’utilisation du PSR-0 et du PSR-4, vous êtes libres, il n’y a pas vraiment de façon meilleure qu’une autre. Juste deux utilisations différentes, répondant à un besoin et une logique différente.

Via le PSR-0, vous ne précisez qu’un namespace “principal”, alors qu’en PSR-4, vous pouvez créer l’illusion d’une structure sans pour autant qu’elle existe.
Si le sujet vous intéresse, vous pouvez aller plus loin via ce lien : https://www.sitepoint.com/battle-autoloaders-psr-0-vs-psr-4/

Vous pouvez également combiner les différents autoload, comme le contao-core le fait, en chargeant toutes les classes dans le namespace Contao\CoreBundle (en PSR-4), puis via l’instruction classmap qui va chercher les classes différement.

"autoload": {
        "psr-4": {
            "Contao\\CoreBundle\\": "src/"
        },
        "classmap": [
            "src/Resources/contao/"
        ],
        "files": [
            "src/Resources/functions/utf8_bootup.php"
        ],
        "exclude-from-classmap": [
            "src/Resources/contao/config/",
            "src/Resources/contao/dca/",
            "src/Resources/contao/helper/",
            "src/Resources/contao/languages/",
            "src/Resources/contao/templates/",
            "src/Resources/contao/themes/"
        ]
    },

Emplacement final et symlink

Via l’instruction Composer “extra”, vous pouvez renseigner la propriété “contao” puis “sources”, qui permet de créer un symlink entre le dossier final qui sera dans le répertoire /vendor de l’application et l’endroit de votre choix.

Historiquement, les modules Contao se trouvaient dans system/modules. Ce pourquoi nous notifions dans chacun de nos modules que Composer doit créer un symlink dans ce répertoire.

Il est beaucoup plus simple pour nous de se souvenir que le dossier se situe dans system/modules/extension/… que dans vendor/webexmachina/extension/…

D’autant plus que cela permet de ne rien avoir à modifier au cas où le nom du dépôt change. Puisque le symlink sera toujours valide.

Backend

Ok, maintenant que la structure est en place, on va pouvoir s’attaquer au backend de notre extension.

Vous devriez toujours commencer par le backend, puisqu’il représente la structure de vos données ainsi que la logique de votre extension.

Configuration

Dans le répertoire /config, vous allez avoir 2 fichiers : “config.php”, “autoload.php”.

config.php

Tout d’abord, ce fichier va permettre d’ajouter notre extension à notre menu Contao, via les lignes ci-dessous :

/**
 * Back end modules
 */
array_insert($GLOBALS['BE_MOD'], 1, array
(
	'wem_plannings' => array
	(
		'wem_planning' => array
		(
			'tables'    => array('tl_wem_planning', 'tl_wem_planning_slot', 'tl_wem_planning_booking_type', 'tl_wem_planning_booking'),
			'icon'		=> 'system/modules/wem-contao-planning/assets/icon_planning.png'
		),
		'wem_booking' => array
		(
			'tables'    		=> array('tl_wem_planning_booking'),
			'confirmBooking' 	=> array('WEM\Planning\Backend\Booking', 'confirmBooking'),
			'denyBooking' 		=> array('WEM\Planning\Backend\Booking', 'denyBooking'),
			'icon'				=> 'system/modules/wem-contao-planning/assets/icon_planning.png'
		),
	)
));

Vous pouvez très bien ajouter votre extension à une section déjà existante, ou bien créer votre propre section. Il suffit de modifier l’endroit où vous insérez votre tableau.

A noter que la fonction array_insert utilisée n’existe pas nativement. C’est un snippet utilisé dans Contao pour insérer un élément dans un tableau PHP existant, mais à un index donné en paramètre.

On peut également décider d’injecter un CSS qui s’occupera de charger des icônes ou de designer des parties du backoffice.
Comme par exemple, ci-dessous, nous avons designer une icône de section, chargée via le CSS backend.css, situé dans le dossier “assets” de notre extension.

if ('BE' === TL_MODE) {
// Load icons in Contao 4.2 backend
$GLOBALS['TL_CSS'][] = 'https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css';
    if (version_compare(VERSION, '4.4', '<')) {
        $GLOBALS['TL_CSS'][] = 'system/modules/wem-contao-planning/assets/backend.css';
    } else {
        $GLOBALS['TL_CSS'][] = 'system/modules/wem-contao-planning/assets/backend_svg.css';
    }
}

Ce fichier est un peu fourre-tout. Il va permettre d’ajouter nos extensions au menu du backoffice Contao, va faire les liens entre les modules disponibles en front et les fichiers, permet la programmations de hooks ou de tâches cron...

Bref, on va le modifier assez régulièrement !

autoload.php

Dans ce fichier, nous allons essentiellement faire des routes entre le système et nos fichiers. Historiquement, ce fichier se chargeait également de charger les classes, mais comme cela a été remplacé par l’autoload de Composer, nous nous contentons principalement de charger des templates avec !

Contao utilise l’extension .html5 pour ces templates. Cela est exactement la même chose que du .html standard. Il n’y a pas de framework derrière cela.
Ainsi, dans l’instruction “TemplateLoader”, nous allons spécifier un tableau avec en clé le nom de notre fichier template, puis en valeur le dossier où il est situé. Rien de compliqué en soit, c’est surtout une histoire de nomenclature, pour ne pas écraser un template qui serait déjà enregistré.

/**
 * Register the templates
 */
TemplateLoader::addFiles(array
(
	'mod_wem_display_planning' => 'system/modules/wem-contao-planning/templates/modules',
	'mod_wem_display_planning_update' => 'system/modules/wem-contao-planning/templates/modules',

	'wem_calendar_default' => 'system/modules/wem-contao-planning/templates/elements',
	'wem_calendar_booking_form' => 'system/modules/wem-contao-planning/templates/elements',
));

Ce système a été pensé car il permet de surcharger aisément les templates natifs de Contao. Par exemple, il existe nativement un template “fe_page” pour s’occuper du squelette global des pages Contao. Et bien vous pouvez en déclarer un avec le même nom, avec un fonctionnement différent.

Ainsi, toutes les références à ce template appellerons votre template plutôt que celui de Contao.

Vous pouvez également charger des classes additionnels, ou même ajouter des namespaces à votre extension. Pour une raison ou une autre, il se peut que vous deviez utiliser une librairie externe sans passer par Composer. C’est un peu plus laborieux certes, mais c’est toutefois possible avec l’instruction ClassLoader.

Enfin, ce fichier n’est nécessaire que dans le cas d’une extension “classique”. Vous pouvez vous en passer si vous développer votre extension comme un bundle Symfony (ca sera pour une autre fois :p).

Pour voir l’ensemble des fonctionnalités de chargement, référez vous aux fichiers suivants : ClassLoader | TemplateLoader

Les Data Container Arrays (DCA)

On le répète assez souvent, un backoffice, c’est basiquement des listes, des formulaires et de la recherche. Plus ou moins agencés de la même façon.

Les Data Container Arrays sont des tableaux PHP qui vont être interprétés et convertit par le système de Contao, en un ensemble de tables SQL et de composants de back-office. Ca semble facile non ?

Parfait ! Parce que ça l’est !

Vous pouvez visualiser ici un DCA complet, commenté par mes soins, pour comprendre à quoi sert chaque ligne présente.

<?php

/**
 * Module Planning for Contao Open Source CMS
 *
 * Copyright (c) 2018 Web ex Machina
 *
 * @author Web ex Machina <https://www.webexmachina.fr>
 */

/**
 * Table tl_wem_planning
 */
$GLOBALS['TL_DCA']['tl_wem_planning'] = array
(

	// Config
	'config' => array
	(
		// Type de DCA souhaité, cela peut être une table de la BDD ou un fichier
		'dataContainer'               => 'Table',
		
		// Ensemble de sous-tables qui seront dépendante de celle-ci
		// Ces prochaines auront la table tl_wem_planning dans la clé 'ptable'
		'ctable'                      => array('tl_wem_planning_slot', 'tl_wem_planning_booking_type', 'tl_wem_planning_booking'),
		
		// Active le versionning pour les éléments de cette table
		'enableVersioning'            => true,
		
		// Instructions SQL, pour définir des clefs
		'sql' => array
		(
			'keys' => array
			(
				'id' => 'primary',
				'alias' => 'index',
			)
		)
	),

	// List
	'list' => array
	(
		// Ordre dans lequel apparaitront les éléments, triés par un mode, une colonne de la base, un "flag" (ordre alphabétique...)
		// panelLayout permet d'activer ou non les fonctions de filtre, recherche et de pagination
		'sorting' => array
		(
			'mode'                    => 1,
			'fields'                  => array('title'),
			'flag'                    => 1,
			'panelLayout'             => 'filter;search,limit'
		),
		
		// Formatage des listes, fields et format fonctionnent ensemble et fonctionne comme un sprintf
		// Ici, le %s de format prendra la valeur de la colonne title de chaque élément
		// label_callback permet de casser le formatage par défaut en appelant une fonction spécifique, qui acceptera la ligne courante en paramètre (voir plus bas)
		'label' => array
		(
			'fields'                  => array('title'),
			'format'                  => '%s',
			'label_callback'			  => array('tl_wem_planning', 'listItems'),
		),
		
		// Les actions globales concernent toutes les lignes. La plus souvent présente est l'instruction "all", qui permet l'édition multiple
		'global_operations' => array
		(
			'all' => array
			(
				'label'               => &$GLOBALS['TL_LANG']['MSC']['all'],
				'href'                => 'act=select',
				'class'               => 'header_edit_all',
				'attributes'          => 'onclick="Backend.getScrollOffset()" accesskey="e"'
			)
		),
		
		// Les operations correspondent aux boutons à droite de chaque ligne
		// Pour chaque bouton, vous précisez un label, un href, une icone, des attributs, ou un callback
		// Le href permet d'appeler des actions spécifiques via les "key", des actions connues via "act" ou d'accéder à une sous table via "table"
		'operations' => array
		(
			'editheader' => array
			(
				'label'               => &$GLOBALS['TL_LANG']['tl_wem_planning']['editheader'],
				'href'                => 'act=edit',
				'icon'                => 'edit.svg',
			),
			'copy' => array
			(
				'label'               => &$GLOBALS['TL_LANG']['tl_wem_planning']['copy'],
				'href'                => 'act=copy',
				'icon'                => 'copy.svg',
			),
			'delete' => array
			(
				'label'               => &$GLOBALS['TL_LANG']['tl_wem_planning']['delete'],
				'href'                => 'act=delete',
				'icon'                => 'delete.svg',
				'attributes'          => 'onclick="if(!confirm(\'' . $GLOBALS['TL_LANG']['MSC']['deleteConfirm'] . '\'))return false;Backend.getScrollOffset()"',
			),
			'show' => array
			(
				'label'               => &$GLOBALS['TL_LANG']['tl_wem_planning']['show'],
				'href'                => 'act=show',
				'icon'                => 'show.svg'
			),
			'bookings' => array
			(
				'label'               => &$GLOBALS['TL_LANG']['tl_wem_planning']['bookings'],
				'href'                => 'table=tl_wem_planning_booking',
				'icon'                => 'system/modules/wem-contao-planning/assets/icon_planning_16.png'
			)
		)
	),

	// Palettes
	// Les palettes représentent l'ensemble des champs qui composeront le "formulaire" de création ou d'édition
	// {title_legend} représente un titre de section
	// , représente une sépération d'un champ à un autre
	// ; représente une sépération d'une section à une autre
	'palettes' => array
	(
		// selector correspond à tous les champs qui vont déclencher des subpalettes, un peu plus bas
		'__selector__'                => array('canUpdateBooking', 'canCancelBooking', 'sendDailySummary', 'protected'),
		// default correspond à la palette générée par défaut. Il est possible d'avoir différentes palettes, dépendante d'un type de champ existant (voir tl_content.php)
		'default'                     => '
			{title_legend},title,alias;
			{default_legend},defaultOpenTime,defaultCloseTime;
			{update_legend},canUpdateBooking;
			{cancel_legend},canCancelBooking;
			{bookingtypes_legend},bookingtypes;
			{slots_legend},slots;
			{summary_legend},sendDailySummary;
			{protected_legend},protected
		'
	),

	// Subpalettes
	// Les sous palettes sont des ensembles de champs déclenchés selon certaines valeurs
	// Pour une checkbox, il suffit de renseigner le champ de la checkbox. Les champs apparaitrons à la suite de cette dernière
	// Pour un select, il convient d'écrire : 'nomduchamp_valeurattendue' => 'champ1,champ2'...
	'subpalettes' => array
	(
		'canUpdateBooking'			  => 'canUpdateBookingUntil,canUpdateBookingLimit',
		'canCancelBooking'			  => 'canCancelBookingUntil',
		'sendDailySummary'			  => 'dailySummaryNotification',
		'protected'                   => 'groups',
	),

	// Fields
	// C'est dans ce tableau que seront configurés chaque champ de votre table.
	'fields' => array
	(
		// Clé primaire de notre table
		// L'ID ne comporte qu'une instruction SQL car c'est un champ automatique
		'id' => array
		(
			'sql'                     => "int(10) unsigned NOT NULL auto_increment"
		),
		// Date de révision de l'item
		// Champ nativement géré par Contao, donc il est également caché
		'tstamp' => array
		(
			'sql'                     => "int(10) unsigned NOT NULL default '0'"
		),
		// Date de création de l'item
		// Caché de l'utilisateur également, c'est un champ que nous rajoutons dans toutes nos tables, des fois que cela puisse servir.
		// A noter que le champ prend le timestamp courant quand on créé l'item
		'created_at' => array
		(
			'default'				  => time(),
			'sql'                     => "int(10) unsigned NOT NULL default '0'"
		),

		// Colonne title
		'title' => array
		(
			// Référence à la traduction que nous verrons plus bas dans l'article
			'label'                   => &$GLOBALS['TL_LANG']['tl_wem_planning']['title'],
			// Désactive le champ pour les non-superadmins. Il peut être mis à disposition via le système de droits
			'exclude'                 => true,
			// Rend le champ recherchable via le panel du haut
			'search'                  => true,
			// Type d'input souhaité
			'inputType'               => 'text',
			// Configuration additionnelles
			// eval est assez fourni, référez vous à cette section : https://docs.contao.org/books/api/dca/reference.html#evaluation
			'eval'                    => array('mandatory'=>true, 'maxlength'=>255, 'tl_class'=>'w50'),
			// Configuration SQL
			'sql'                     => "varchar(255) NOT NULL default ''"
		),
		
		// Colone alias
		'alias' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_wem_planning']['alias'],
			'exclude'                 => true,
			'inputType'               => 'text',
			'eval'                    => array('rgxp'=>'alias', 'doNotCopy'=>true, 'unique'=>true, 'maxlength'=>128, 'tl_class'=>'w50'),
			// Execute la fonction generateAlias de la classe tl_wem_planning  à la sauvegarde du formulaire.
			// Vous pouvez ajouter autant de fonction que souhaité au tableau.
			'save_callback' => array
			(
				array('tl_wem_planning', 'generateAlias')
			),
			'sql'                     => "varchar(128) COLLATE utf8_bin NOT NULL default ''"
		),

		'defaultOpenTime' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_wem_planning']['defaultOpenTime'],
			'default'                 => time(),
			'exclude'                 => true,
			'inputType'               => 'text',
			'eval'                    => array('rgxp'=>'time', 'doNotCopy'=>true, 'tl_class'=>'w50'),
			'sql'                     => "int(10) unsigned NOT NULL default '0'"
		),
		'defaultCloseTime' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_wem_planning']['defaultCloseTime'],
			'default'                 => time(),
			'exclude'                 => true,
			'inputType'               => 'text',
			'eval'                    => array('rgxp'=>'time', 'doNotCopy'=>true, 'tl_class'=>'w50'),
			'sql'                     => "int(10) unsigned NOT NULL default '0'"
		),

		'canUpdateBooking' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_wem_planning']['canUpdateBooking'],
			'exclude'                 => true,
			// Permet le filtre des items via ce champ
			'filter'                  => true,
			// Et ordonnera les résultats via ce flag
			// Plus de références sur les flags : https://docs.contao.org/books/api/dca/reference.html#sorting
			'flag'                    => 1,
			'inputType'               => 'checkbox',
			'eval'                    => array('doNotCopy'=>true, 'submitOnChange'=>true),
			'sql'                     => "char(1) NOT NULL default ''"
		),
		'canUpdateBookingUntil' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_wem_planning']['canUpdateBookingUntil'],
			'exclude'                 => true,
			'inputType'               => 'text',
			'eval'                    => array('doNotCopy'=>true, 'tl_class'=>'w50'),
			'sql'                     => "int(10) unsigned NOT NULL default '0'"
		),
		'canUpdateBookingLimit' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_wem_planning']['canUpdateBookingLimit'],
			'exclude'                 => true,
			'inputType'               => 'text',
			'eval'                    => array('doNotCopy'=>true, 'tl_class'=>'w50'),
			'sql'                     => "int(10) unsigned NOT NULL default '0'"
		),

		'canCancelBooking' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_wem_planning']['canCancelBooking'],
			'exclude'                 => true,
			'filter'                  => true,
			'flag'                    => 1,
			'inputType'               => 'checkbox',
			'eval'                    => array('doNotCopy'=>true, 'submitOnChange'=>true),
			'sql'                     => "char(1) NOT NULL default ''"
		),
		'canCancelBookingUntil' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_wem_planning']['canCancelBookingUntil'],
			'exclude'                 => true,
			'inputType'               => 'text',
			'eval'                    => array('doNotCopy'=>true, 'tl_class'=>'w50'),
			'sql'                     => "int(10) unsigned NOT NULL default '0'"
		),

		// Champ particulier, car il fait appel au composant dcaWizard
		// Ce composant affiche le contenu d'une sous-table, ici : tl_wem_planning_booking_type
		// On notera qu'il n'y a pas de clef sql, puisqu'on n'a rien à sauvegarder ici
		'bookingtypes' => array
		(
			'label'                 => &$GLOBALS['TL_LANG']['tl_wem_planning']['bookingtypes'],
		    'inputType'             => 'dcaWizard',
		    'foreignTable'          => 'tl_wem_planning_booking_type',
		    'foreignField'          => 'pid',
		    'params'                  => array
		    (
		        'do'                  => 'wem_planning',
		    ),
		    'eval'                  => array
		    (
		        'fields' => array('title', 'duration'),
		        'editButtonLabel' => $GLOBALS['TL_LANG']['tl_wem_planning']['edit_bookingtype'],
		        'applyButtonLabel' => $GLOBALS['TL_LANG']['tl_wem_planning']['apply_bookingtype'],
		        'orderField' => 'sorting',
		        'showOperations' => true,
		        'operations' => array('edit', 'delete'),
		        'tl_class'=>'clr',
		    ),
		),

		'slots' => array
		(
			'label'                 => &$GLOBALS['TL_LANG']['tl_wem_planning']['slots'],
		    'inputType'             => 'dcaWizard',
		    'foreignTable'          => 'tl_wem_planning_slot',
		    'foreignField'          => 'pid',
		    'params'                  => array
		    (
		        'do'                  => 'wem_planning',
		    ),
		    'eval'                  => array
		    (
		        'fields' => array('canBook', 'type'),
		        'editButtonLabel' => $GLOBALS['TL_LANG']['tl_wem_planning']['edit_slot'],
		        'applyButtonLabel' => $GLOBALS['TL_LANG']['tl_wem_planning']['apply_slot'],
		        'orderField' => 'sorting',
		        'showOperations' => true,
		        'operations' => array('edit', 'delete'),
		        'tl_class'=>'clr',
		    ),
		),

		'sendDailySummary' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_wem_planning']['sendDailySummary'],
			'exclude'                 => true,
			'filter'                  => true,
			'inputType'               => 'checkbox',
			'eval'                    => array('submitOnChange'=>true),
			'sql'                     => "char(1) NOT NULL default ''"
		),
		
		// Ce champ n'est affiché que si "sendDailySummary" est coché.
		'dailySummaryNotification' => array
		(
			'label'                     => &$GLOBALS['TL_LANG']['tl_wem_planning']['dailySummaryNotification'],
			'exclude'                   => true,
			'inputType'                 => 'select',
			// On charge les options via la fonction getNotificationChoices de la classe tl_wem_planning
			'options_callback'          => array('tl_wem_planning', 'getNotificationChoices'),
			'eval'                      => array('includeBlankOption'=>true, 'chosen'=>true, 'tl_class'=>'clr'),
			'sql'                       => "int(10) unsigned NOT NULL default '0'"
		),

		'protected' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_wem_planning']['protected'],
			'exclude'                 => true,
			'filter'                  => true,
			'inputType'               => 'checkbox',
			'eval'                    => array('submitOnChange'=>true),
			'sql'                     => "char(1) NOT NULL default ''"
		),
		'groups' => array
		(
			'label'                   => &$GLOBALS['TL_LANG']['tl_wem_planning']['groups'],
			'exclude'                 => true,
			'inputType'               => 'checkbox',
			// Les options d'un input peuvent également se faire via une référence à une autre table
			// Les options retournées auront comme value l'ID de tl_member_group et comme label le name de tl_member_group
			'foreignKey'              => 'tl_member_group.name',
			'eval'                    => array('mandatory'=>true, 'multiple'=>true),
			'sql'                     => "blob NULL",
			// Il convient de définir la relation si une référence à une autre table est faite
			'relation'                => array('type'=>'hasMany', 'load'=>'lazy')
		),
	)
);


/**
 * Provide miscellaneous methods that are used by the data configuration array.
 *
 * @author Web ex Machina <https://www.webexmachina.fr>
 */
class tl_wem_planning extends Backend
{

	/**
	 * Import the back end user object
	 */
	public function __construct()
	{
		parent::__construct();
		$this->import('BackendUser', 'User');
	}

	/**
	 * Auto-generate the news alias if it has not been set yet
	 *
	 * @param mixed         $varValue
	 * @param DataContainer $dc
	 *
	 * @return string
	 *
	 * @throws Exception
	 */
	public function generateAlias($varValue, DataContainer $dc)
	{
		$autoAlias = false;

		// Generate alias if there is none
		if ($varValue == '')
		{
			$autoAlias = true;
			$varValue = StringUtil::generateAlias($dc->activeRecord->title);
		}

		$objAlias = $this->Database->prepare("SELECT id FROM tl_wem_planning WHERE alias=? AND id!=?")
								   ->execute($varValue, $dc->id);

		// Check whether the news alias exists
		if ($objAlias->numRows)
		{
			if (!$autoAlias)
			{
				throw new Exception(sprintf($GLOBALS['TL_LANG']['ERR']['aliasExists'], $varValue));
			}

			$varValue .= '-' . $dc->id;
		}

		return $varValue;
	}

	/**
	 * List a planning
	 *
	 * @param array $arrRow
	 *
	 * @return string
	 */
	public function listItems($arrRow)
	{
		$strHtml = '<div class="tl_content_left tl_wem_planning">';

		// Count drafted & confirmed booking
		$objPendingBooking = $this->Database->prepare('SELECT id FROM tl_wem_planning_booking WHERE pid=? AND status="pending"')->execute($arrRow['id']);
		$objConfirmedBooking = $this->Database->prepare('SELECT id FROM tl_wem_planning_booking WHERE pid=? AND status="confirmed"')->execute($arrRow['id']);
		
		$strHtml .= '<strong>'.$arrRow['title'].'</strong> ('.$objPendingBooking->count().' réservations à traiter et '.$objConfirmedBooking->count().' confirmées)';


		$strHtml .= '</div>';

		return $strHtml;
	}

	/**
     * Get notification choices
     *
     * @return array
     */
    public function getNotificationChoices()
    {
        $arrChoices = array();
        $objNotifications = \Database::getInstance()->execute("SELECT id,title FROM tl_nc_notification WHERE type='daily_bookings' ORDER BY title");

        while ($objNotifications->next()) {
            $arrChoices[$objNotifications->id] = $objNotifications->title;
        }

        return $arrChoices;
    }
}

Vous expliquer de long en large toutes les possibilités des DCAs de Contao est improbable pour faire un article court, mais vous êtes là pour apprendre, donc voici quelques astuces :

1. Recherchez toujours si quelque chose de similaire existe

En effet, dans un back-office, tout se ressemble et tout a plus ou moins été fait. Contao dispose d’un assez grand nombre de modules natifs qui englobe la totalité des composants backend disponibles. N’hésitez pas à piquer des DCA qui ressemblent à ce que vous voulez faire, puis à les adapter.

2. On ouvre le cookbook > https://docs.contao.org/books/api/dca/reference.html

Quand on attaque l’apprentissage d’un système, on passe en revue la documentation. Voir ce qui existe permet de comprendre un peu mieux le système.

3. Cassez tout

Quoi de mieux pour apprendre que de modifier l’existant ? L’avantage de l’informatique, c’est que vous pouvez créer une installation en 15 minutes, tout casser, et recommencer ! Donc oui, c’est pas bien de modifier les fichiers du CMS directement, mais là c’est pour la bonne cause.

4. Surcharger les DCAs existants

Avec Contao, il est possible de modifier chaque DCA. Après tout, ce sont des tableaux PHP globaux, donc modifiez les comme tel !
Vous n’avez qu’à nommer le fichier comme se nomme le fichier à modifier et Contao les fusionnera automatiquement.

<?php

/**
 * Module Planning for Contao Open Source CMS
 *
 * Copyright (c) 2018 Web ex Machina
 *
 * @author Web ex Machina <https://www.webexmachina.fr>
 */

// Ajout d'une palette dans le DCA tl_module
$GLOBALS['TL_DCA']['tl_module']['palettes']['wem_display_planning']    = '{title_legend},name,headline,type;{config_legend},wem_planning;{template_legend:hide},customTpl;{protected_legend:hide},protected;{expert_legend:hide},guests,cssID';

// Ajout d'un champ au DCA et à la table tl_module
$GLOBALS['TL_DCA']['tl_module']['fields']['wem_planning'] = array
(
	'label'                   => &$GLOBALS['TL_LANG']['tl_module']['wem_planning'],
	'exclude'                 => true,
	'inputType'               => 'select',
	'foreignKey'              => 'tl_wem_planning.title',
	'eval'                    => array('chosen'=>true, 'mandatory'=>true, 'tl_class'=>'w50'),
	'sql'                     => "int(10) unsigned NOT NULL default '0'"
);

La mise à jour de la base de données

Depuis Contao 4, les fichiers config, DCA et languages sont compilés et mis en cache. Donc pensez bien à purger votre cache via le Contao Manager, onglet maintenance quand vous les modifiez !

Puis, si vous avez modifier votre base de données, utilisez l’onglet Contao install pour voir les modifications demandées et les mettre en application.

Enfin, rafraîchissez votre backend et poursuivez !

A noter qu’il existe aussi un bon nombre de composants backend intéressants disponibles sur Packagist. En voici deux que je trouve particulièrement intéressants :

dcaWizard : Permet d’afficher un DCA directement dans son DCA parent. Cela est très utile pour éviter d’imbriquer une grande quantité de données.

multiColumnWizard : Permet de créer des structures de données complexes qui ne requiert toutefois pas d’être accessibles autrement que dans un blob.

Pour plus de composants développés par la communauté, c’est par là : https://packagist.org/?query=wizard%20contao

Languages / Traductions

Il est extrêmement facile de faire une extension backoffice multilingue dans Contao puisque le système l’intègre nativement.

Pour se faire, vous devez créer un fichier portant le nom de votre fichier DCA, avec l’extension xlf et le placer dans le dossier de la langue correspondante.

Exemple :

  • dca
    • tl_news.php
  • languages
    • en
      • tl_news.xlf
    • fr
      • tl_news.xlf

Ensuite, la structure s’écrit comme un fichier XML. Vous pouvez créer une arborescence sur 4 niveaux maximum. C’est amplement suffisant pour le back-office, et en front, on se débrouille toujours !

<!-- L'instruction suivante sera accessible via le code PHP : &$GLOBALS['TL_LANG']['tl_wem_planning']['title'] -->
<trans-unit id="tl_wem_planning.title.0">
	<source>Title</source>
	<target>Intitulé</target>
</trans-unit>
<trans-unit id="tl_wem_planning.title.1">
	<source>Please enter a planning title.</source>
	<target>Saisir un titre de planning.</target>
</trans-unit>

A noter que vous avez deux entrées, le 0 traduit le label de votre champ, tandis que le 1 traduit l’explication située sous le champ.

Vous pouvez trouver également des fichiers de langues en PHP, car historiquement, c’était ainsi qu’ils étaient écrits. Personnellement, je préfère l’XLF pour éviter la prise de tête avec les guillemets, mais ce n’est que moi :D

Assets

Comme dit précédemment, le dossier assets va contenir les ressources publiques de votre extension. Il devra donc obligatoirement contenir un fichier .htaccess autorisant l’accès aux répertoires et fichiers.

<IfModule !mod_authz_core.c>
  Order allow,deny
  Allow from all
</IfModule>
<IfModule mod_authz_core.c>
  Require all granted
</IfModule>

Ensuite, c’est complètement libre. Vous pouvez organiser ça comme il vous semble bon. Pour une structure complète, voilà ce qu’on fait chez nous par exemple :

  • backend : Contient toutes les ressources utilisées par le backend exclusivement
  • css : Contient tous les styles frontend liés à l’extension
  • js : Contient tous les scripts frontend liés à l’extension
  • medias : Contient tous les medias frontend liés à l’extension
  • vendor : Contient toutes les librairies frontend liées à l’extension

Chaque dossier est optionnel et tout dépend de votre extension. Vous pouvez tout coller à la racine, tant que le système y a accès et que les chemins sont bons…

Suite au prochain épisode !

OK, il se trouve que cet article est beaucoup plus long que prévu, donc on va le couper en deux ! Comme ça, vous avez le temps d’appréhender votre backend avant d’attaquer le frontend.

Rappelez vous bien que cet article ne fait que présenter les grandes lignes du développement d’une extension Contao. Il existe bien des façons de développer et Contao n’est pas exempt de cela.

Dans un futur proche, nous verrons donc les différentes façons de préparer le frontend de notre extension ainsi que quelques trucs et astuces pour bien gérer un développement sous Contao 4.