Utiliser Behat et Mink dans une application Laravel 4

Fan de développement web élégant basé sur le framework PHP Laravel, et féru de Behavior-Driven Development à grands coups de Behat et Mink ? Si tel est le cas, il est possible que vous ayez rencontré quelques difficultés à faire cohabiter les deux via Composer, notamment avec la quatrième et dernière version en date du framework. Il n'est pourtant pas vraiment en cause ; je pense pouvoir affirmer sans trop me tromper que gérer ces deux outils de tests fonctionnels en tant que dépendances est un beau bordel actuellement, entre la version 2.5 "stable" et la version 3.0 encore-en-beta-mais-qui-sort-quand-même-par-défaut-un-peu-trop-facilement, mais passons.

Dans mon cas, la première difficulté rencontrée a donc principalement été liée à des conflits de dépendances. Qu'à cela ne tienne, ce paquet Composer, sobrement intitulé behat-laravel, m'a été fort utile pour pouvoir tout installer simplement. Voici un fichier composer.json rudimentaire avec les packages employés :

{
"require": {
"laravel/framework": "4.1.*",
"phpunit/phpunit": "3.8.*@dev",
"guilhermeguitte/behat-laravel": "dev-master",
"behat/mink": "[email protected]",
"behat/mink-extension": "*",
"behat/mink-selenium2-driver": "*"
},

...
}

Une fois le tout prêt à l'emploi, on commence donc à écrire ses premiers tests pour vite se rendre compte que l'écosystème global de Laravel, très complet, s'avère difficilement dispensable lorsqu'on souhaite n'utiliser que certaines parties du framework. Pour cet exemple qui sent bon le vécu, imaginons que nous souhaitions tester un formulaire de connexion et que nous voulions donc pouvoir créer un utilisateur en base de données histoire d'avoir une paire d'identifiants valides à tester. Voici le fichier .feature correspondant :

Feature: Log in to the app
In order to be able to access my account
I need to be able to fill in a login form with my credentials

Background:
Given I am on "/"

Scenario: Type in a valid account's credentials
Given "[email protected]" has an account with "prout" as the password
When I fill in "Adresse e-mail" with "[email protected]"
And I fill in "Mot de passe" with "prout"
And I press "Connexion"
Then I should be on "/account"

Scenario: Type in a invalid account's credentials
When I fill in "Adresse e-mail" with "[email protected]"
And I fill in "Mot de passe" with "nothing"
And I press "Connexion"
Then I should be on "/"
And I should see "Votre identification a échoué, veuillez réessayer"

Scenario: Submit the form without filling it
When I press "Connexion"
Then I should be on "/"
And I should see "Adresse e-mail : ce champ est requis"
And I should see "Mot de passe : ce champ est requis"

Scenario: Submit the form with an invalid e-mail address in
When I fill in "Adresse e-mail" with "jesuisinvalidelol"
And I press "Connexion"
Then I should be on "/"
And I should see "Adresse e-mail : ce champ est invalide"

Le vocabulaire de base de Mink couvrira à lui seul l'essentiel de ces instructions. Nous allons simplement devoir donner du sens à l'expression Given /^"([^"]*)" has an account with "([^"]*)" as the password$/ :

<?php

use Behat\Behat\Exception\PendingException;
use Behat\MinkExtension\Context\MinkContext;

class LoginContext extends MinkContext
{
/**
* @Given /^"([^"]*)" has an account with "([^"]*)" as the password$/
*/
public function hasAnAccountWithAsThePassword($email, $password)
{
$user = new User();
$user->name = 'toto';
$user->email = $email;
$user->setPassword($password);
$user->save();
}
}

Enfantin, n'est-il pas ? Oui, sauf que dans les faits :

  • On n'a pas accès à la base de données
  • La classe User hérite d'Eloquent, qui est un alias d'une classe namespacée, accessible uniquement dans le contexte d'une application Laravel
  • Même topo pour Hash, qui de plus doit être initialisé avec un provider de hash (de mon temps, on appelait ça un dealer)

Je pourrais vous guider pas à pas, d'erreur d'exécution en erreur d'exécution, à travers les méandres de mon parcours sur le chemin tortueux menant à la résolution du problème exposé ici, mais je doute que ce soit vraiment intéressant. Voyons donc directement les modifications apportées à cette classe de contexte pour faire tourner le bazar :

<?php

use Behat\Behat\Exception\PendingException;
use Behat\MinkExtension\Context\MinkContext;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\AliasLoader;
use Illuminate\Hashing\HashServiceProvider;
use Illuminate\Support\Facades\Facade;
use Illuminate\Database\Capsule;

class LoginContext extends MinkContext
{
public function __construct()
{
// On instancie une app bidon
$app = new Application();

// On lui évite de faire appel à des propriétés indéfinies
$app->bind(
'path.storage',
function () {
return '';
}
);

// On instancie un fournisseur de chiffrement (belle francisation, tiens)
$hash = new HashServiceProvider($app);
$hash->register();

// On utilise notre fausse app
Facade::setFacadeApplication($app);

// On charge les alias existants dans une instance de la classe qui va bien
$appConfig = require(dirname(dirname(dirname(__DIR__))).'/config/app.php');
$this->aliasLoader = AliasLoader::getInstance($appConfig['aliases']);

// On récupère les infos de connexion à la base de données de test
$dbConfig = require(dirname(dirname(dirname(__DIR__))).'/config/database.php');
$this->db = new Capsule\Manager();
$this->db->addConnection($dbConfig['connections']['sqlite_test']);
}



/**
* @Given /^"([^"]*)" has an account with "([^"]*)" as the password$/
*/
public function hasAnAccountWithAsThePassword($email, $password)
{
// On charge les alias dont on va avoir besoin
$this->aliasLoader->load('Eloquent');
$this->aliasLoader->load('Hash');

// On boot Eloquent avec notre instance de la base de données
$this->db->bootEloquent();

// On peut exécuter des requêtes arbitraires comme ceci :
$this->db->getConnection()->delete('DELETE FROM users');

$user = new User();
$user->name = 'toto';
$user->email = $email;
$user->setPassword($password);
$user->save();

// On est bien \o/
}
}

Voilà qui devrait vous donner une meilleure idée du type de bidouille (car c'est bien ce dont il s'agit) envisageable pour pouvoir utiliser une partie de l'environnement de Laravel sans démarrer complètement l'application. Je suis bien évidemment preneur de toute solution plus solide, n'hésitez pas à laisser un petit mot doux au bas de cet article si le coeur vous en dit !

Un plugin jQuery pour des images en lazy-load

Voici un petit nouveau : un , le bien nommé Lazy ! Passez-lui un sélecteur afin de charger puis d'afficher en fondu les éléments img ciblés au fil du scroll (ou directement pour ceux déjà présents dans le viewport). Pour un rendu optimal, pensez à fixer leur taille dans le code HTML ou CSS !

Découvrez-en plus - et comme toujours, contribuez si vous le voulez ! - sur la page GitHub du projet.

Utiliser le même langage de templating en front-end et en back-end

Si vous utilisez un framework JavaScript front-end tel AngularJS, peut-être avez-vous déjà souhaité pouvoir gérer les templates partiels utilisés par celui-ci de manière uniforme avec vos layouts (qui, à n'en pas douter, seront quant à eux générés en back-end). Une astuce simple et efficace consiste à rendre ceux-ci disponibles via une route de votre application back-end, dont l'unique rôle sera d'en effectuer le rendu afin que le front-end puisse prendre le relais. Au final, le résultat sera le même que la solution basique consistant à entreposer des fichiers HTML dans un dossier public de votre application, la flexibilité et l'uniformité en plus !

Voyons donc un exemple rapide avec la stack MEAN. Côté Express :

app.get('/partials/:name.html', function (req, res) {
res.render(path.join('partials', req.params.name));
});

Le chemin sera bien évidemment à adapter selon votre cas : je suppose ici que les templates concernés se trouvent dans un dossier partials, lui-même enfant direct de votre répertoire de vues principal.

Pour récupérer le tout sur Angular, il suffira donc tout simplement de faire quelque chose de ce genre :

var app = angular.module('myApp', ['ngRoute']);

app.config(['$routeProvider', function ($locationProvider, $httpProvider, $routeProvider)
{
$routeProvider
.when('/some-route', {
templateUrl: '/partials/some-template.html',
controller: 'SomeCtrl'
})

.when('/some-other-route', {
templateUrl: '/partials/some-other-template.html',
controller: 'SomeOtherCtrl'
});
}]);

Ceci vous donne l'avantage non négligeable de pouvoir traiter certains aspects de vos partials depuis le back-end, si vous en avez le besoin.

J'oubliais : bonne année à tous !

PrestaShop : de l'usage du champ Position d'un modèle dans le contrôleur de back-office correspondant

Si vous développez régulièrement sur PrestaShop, il y a fort à parier que vous avez déjà écrit vous-même des classes héritant d'AdminController afin d'offrir à vos utilisateurs une interface en back-office leur permettant de gérer les fonctionnalités que vous mettez en place.

Dans le cas d'un module par exemple, vous avez peut-être suivi cet excellent tutoriel de manière à permettre l'administration des entités gérées par ledit module.

Si vous avez ensuite jugé qu'il serait utile d'ajouter aux entités en question un champ position, dont la valeur serait prise en compte lors de l'affichage en front-office (et se présentant en back-office de manière similaire à ce que l'on peut trouver sur les catégories par exemple), je peux cette fois affirmer sans trop risquer de me tromper que vous n'êtes pas près d'oublier la torture mentale qui a suivi. En effet, cet aspect a beau être totalement (ou presque) géré par PrestaShop nativement, l'absence totale de documentation à ce sujet peut s'avérer pour le moins handicapante. Je vous propose donc un petit récapitulatif !

Premièrement, votre modèle doit bien évidemment inclure le champ correspondant :

class MyModel extends ObjectModel
{
public $id_model; // clé primaire
public $position;
// ...

public static $definition = array(
// ...

'fields' => array(
// ...
'position' => array(
'type' => ObjectModel::TYPE_INT
),
// ...
)
);

J'affirmais tantôt que PrestaShop sait gérer "nativement" l'implémentation d'un champ position sur un modèle : ce n'est pas tout à fait vrai, étant donné que les parties du code source dédiées à cet aspect font référence en dur à des champs liés aux catégories (de produit ou de pages CMS), qui sont les seules à en faire usage par défaut. Nous allons donc devoir mettre un peu les mains dans le cambouis pour combler les trous et répliquer ce comportement pour nos propres entités.

Continuons donc sur notre modèle, et créons tout d'abord une méthode pour récupérer facilement la prochaine valeur de position disponible :

public static function getNextAvailablePosition()
{
$sql = 'SELECT position FROM '._DB_PREFIX_.self::$definition['table'].' ORDER BY position DESC';

$position = (int)Db::getInstance()->getValue($sql, false);
return $position + 1;
}

Nous allons ensuite nous assurer que ce champ est correctement renseigné lors de l'enregistrement en base de données d'une nouvelle instance de notre modèle, en étendant la méthode processAdd du contrôleur d'admin de notre module :

public function processAdd()
{
$object = parent::processAdd();
$object->position = MyModel::getNextAvailablePosition();

$object->id_model = $object->id; // apparemment nécessaire si votre clé primaire porte un nom autre qu'"id"
$object->update();

return $object;
}

Si vous testez ce que nous venons d'ajouter maintenant, tout devrait fonctionner correctement en apparence (du moins, en ce qui concerne les valeurs sauvegardées). Voyons maintenant comment "câbler" cette fonctionnalité sur l'interface d'administration.

Ajoutons tout d'abord notre champ position à la vue en liste de nos entités, dans notre classe fille de ModuleAdminController :

public function __construct()
{
// ...
$this->orderBy = 'position'; // on définit le critère de tri par défaut
$this->position_identifier = 'id_model'; // on rappelle la clé primaire du modèle (sans quoi les liens générés seront incorrects)

parent::__construct();

$this->fields_list = array(
// ...
'position' => array(
'title' => $this->l('Position'),
'position' => 'position' // toute la "magie" est là...
),
// ...
);

La nouvelle colonne que vous verrez apparaître contiendra soit des liens contenant des icônes en forme de flèche (le résultat souhaité !), soit des chiffres. Dans ce second cas, assurez-vous que votre liste est actuellement triée selon notre nouveau champ position dans le sens ascendant, ainsi que nous l'avons défini dans le code (en pratique, c'est le dernier critère sélectionné par l'utilisateur qui prévaut).

Une fois les liens permettant de modifier la position d'un élément visibles sur votre vue en liste, nul doute que vous allez sentir l'espoir renaître et les ténèbres se dissiper. Et pourtant, si vous cliquez sur l'un d'eux, vous aurez droit à un résultat des plus somptueux :

Fatal error: Call to undefined method MyModel::updatePosition() in [...]

En effet, la classe de base ObjectModel ne définit pas cette fonctionnalité : le soin est laissé aux classes filles de l'implémenter selon leurs propres besoins le cas échéant. En ce qui nous concerne, nous pouvons y procéder rapidement ainsi :

/**
* @param $way Sens du changement de position (0 si l'élément monte, 1 s'il descend)
* @param $position La position qu'aura l'élément _après_ ledit mouvement
*
* @return Booléen indiquant la réussite ou l'échec de l'opération
*/
public function updatePosition($way, $new_position)
{
$db = Db::getInstance();
$count = $db->getValue('SELECT COUNT(*) FROM '._DB_PREFIX_.self::$definition['table'], false);

if (($new_position >= 1) && ($new_position <= $count)) {
$old_position = $way ? ($new_position - 1) : ($new_position + 1);

if (($old_position >= 1) && ($old_position <= $count)) {
$sql = implode(
';',
array(
'UPDATE '._DB_PREFIX_.self::$definition['table'].' SET position = 0 WHERE position = '.(int)$new_position,
'UPDATE '._DB_PREFIX_.self::$definition['table'].' SET position = '.(int)$new_position.' WHERE position = '.(int)$old_position,
'UPDATE '._DB_PREFIX_.self::$definition['table'].' SET position = '.(int)$old_position.' WHERE position = 0'
)
);

// L'ancienne et la nouvelle position sont valides, on les intervertit
return $db->execute($sql);
}
}

return false;
}

Voilà qui est mieux ! N'oublions pas non plus de réassigner proprement la position des éléments en cas de suppression de l'un d'eux. À cette occasion, nous revenons sur le modèle et lui ajoutons la méthode suivante :

public function delete()
{
$position = $this->position;

if ($result = parent::delete()) {
Db::getInstance()->execute('UPDATE '._DB_PREFIX_.self::$definition['table'].' SET position = position-1 WHERE position > '.(int)$position);
}

return $result;
}

Enfin, en bonus, nous allons faire en sorte que le changement de position par drag'n'drop dans la colonne correspondante ait un réel effet (pour l'instant, la modification se produit visuellement mais n'est pas persistée) en ajoutant la méthode suivante à notre contrôleur :

public function ajaxProcessUpdatePositions()
{
if (isset($_POST['id']) && isset($_POST['way'])) {
$object = new MyModel($_POST['id']);

if ($object->id_model) {
$new_position = $object->position;
$_POST['way'] ? $new_position++ : $new_position--;

if ($object->updatePosition($_POST['way'], $new_position)) {
die;
}

die('{"hasError": true, errors: "Cannot update position"}');
}
}

die('{"hasError": true, errors: "This item can not be loaded"}');
}

Le nom de cette méthode obéit à une convention (secrète ?) de PrestaShop qui lui permet d'être exécutée automatiquement lorsque la requête AJAX idoine est lancée.

Ouf, nous y voilà parvenus ! J'ai écrit cet article à la suite de mes propres tâtonnements, aussi n'hésitez pas à le commenter si certains détails sont inexacts ou imprécis et que, fort de votre propre galère expérience, vous pouvez les améliorer.

Un plugin jQuery pour redimensionner automatiquement vos textarea

Je vais aujourd'hui vous parler de Crusher, un petit destiné à faire en sorte que vos textarea se redimensionnent automatiquement. Léger (il repose en bonne partie sur l'application de styles CSS) et facile d'utilisation, il s'agit (à mon sens !) d'une bonne solution pour réaliser cet effet simplement.

Comme d'habitude, n'hésitez pas à participer au projet en vous rendant sur sa page GitHub !