Disclaimer : Ce billet va être long... peut être trop long même. J'aurais bien pu le couper en 3 parties, histoire de poster un peu plus, mais techniquement il n'y aurait aucun intérêt a faire ca. Ce billet représente un "tout", qui n'a vraiment de sens qu'entier.
Deuxième point : il va y avoir du code. Du gros, du lourd qui tâche. Si vous n'aimez pas le code, vous pouvez arrêter de lire ici, je ne vous en voudrais pas ;).
Troisième point : Le code présenté ici n'est pas "parfait". Les commentaires ne sont pas a 100% "Drupal way of life" (sans compter qu'ils sont en français), et certaines fonctions pourraient être regroupés en une seule. La séparation est ici voulue dans le seul but de montrer l'action spécifique de chacune de celle-ci.
Ceci dis, que va-t-on trouver ici ? Je vais donc couvrir dans ce billet :
- La création d’un type d’entité, pouvant recevoir des fields
- La création, hardcodé, de deux bundles pour cette entité
- La création/édition/suppression d’entités, avec affichage des éventuels fields pouvant y être attachés
- Exposer les champs de la « base table » de l’entité dans Views
Par contre, on ne va pas couvrir ici :
- La création à la volée de bundles (par soucis de simplicité)
- L’utilisation du module contrib EntityAPI
- Nous ne parlerons pas non plus de la démonstration du dernier théorème de Fermat
Aller, quand il faut se lancer, il faut ...
Notre première entité
Pour gérer sa bibliothèque, Martine souhaiterait utiliser Drupal pour réaliser une petite bibliothèque en ligne. Comme Martine est un peu geek sur les bords, elle va évidement partir sur un Drupal 7, et en plus, elle ne va pas utiliser un content type pour gérer ses livres mais elle va plutôt créer une nouvelle entité "books" spécifique à ses besoins.
Pour ce faire, elle va créer un nouveau module nommé "Books"
Pour commencer, Martine doit renseigner la structure de la base qu'elle va utiliser.
Rien de bien compliqué ici. C'est un hook_schema tout a fait classique, dans le .install du module.
function books_schema() {
$schema['books'] = array(
'fields' => array(
'bid' => array(
'type' => 'serial',
'unsigned' => TRUE,
'not null' => TRUE
),
'type' => array(
'description' => 'the bundle property',
'type' => 'text',
'size' => 'medium',
'not null' => TRUE
),
'name' => array(
'type' => 'text',
'size' => 'medium'
)
),
'primary key' => array('bid')
);
return $schema;
}
Passons aux choses sérieuses, dans le .module
Tout d’abord, Martine commence par utiliser le hook hook_entity_info(). Ce dernier va lui permettre de définir sa nouvelle entité.
Elle commence par y renseigner ce que nous allons appeler le « type » de l’entité.
$entity['books'] = array (
'label' => t('Book'),
// la table definie dans books.install
'base table' => 'books',
'uri callback' => 'books_uri',
// on peut y attacher des fields
'fieldable' => TRUE,
'entity keys' => array(
// utilisé par entity_load pour requeter la base table
'id' => 'bid' ,
// utilisé par field_attach api pour charger les fields attachés
'bundle' => 'type'
),
'bundle keys' => array('bundle' => 'type'),
'bundles' => array()
);
Martine est quand même super sympa, elle a mis plein de commentaires pour essayer d'expliquer. Ca devrait suffire à la plupart des personnes.
Ce code va donc créer une entité dénommé "books", qui va se baser sur la table nommé "books" dans la BDD. Elle va pouvoir accepter de recevoir de nouveaux fields, et elle a pour nom de Bundle "type" (ca sera utile juste après).
Une entité, c'est beau. Avoir plusieurs bundle dedans, c'est encore mieux. Et comme Martine souhaite renseigner des informations aussi variables que roman ou des BD, il est bon de créer ces 2 bundles.
Pourquoi ne pas juste jouer sur les fields attaché ? On pourrait, mais si on réfléchie un peu, les livres vont partager des éléments commun. Par exemple, tout livre a un auteur, un titre, un nombre de page. Mais par contre, seuls les BD ont, potentiellement, un illustrateur. Une information permettant de savoir si la BD est en couleur ou non. Ces deux champs supplémentaires non rien a faire dans la base table books, car toutes les bundles (roman, BD, ...) ne vont pas les partager. D'où, stockage dans la base des informations de base, et utilisation de bundles qui porterons ces informations.
Pour des raisons de simplicité, Martine ne stockera ici que le Nom du livre. Et uniquement deux Bundles seront créé, directement dans le code, à savoir "Roman" et "BD".
Martine créer donc son Bundle "Roman", toujours dans le hook hook_entity_info().
$entity['books']['bundles']['roman'] = array(
'label' => t('Roman'),
'admin' => array(
// le chemin defini dans books_menu
// Le Field api utilise ceci pour ajouter ses éléments MENU_LOCAL_TASK
// qui servent a administer et afficher les fields
'path' => 'admin/books/%book_type',
// le "real path" une fois l'argument chargé
'real path' => 'admin/books/roman',
// %books_type, c'est arg(2), donc on le passe ...
'bundle argument' => 2,
// on reste en "administer nodes" juste pour ne pas s'embêter avec les permissions :)
'access arguments' => array('administer nodes')
)
);
Les deux éléments important ici, le path et le real path.
Le "path" donne le chemin interne a Drupal a laquelle va répondre l'administration de notre entité, en y passant le "type" (comprendre le Bundle) de notre entité. Le "real path" donne l'url "user friendly" de cette même page.
Martine fait pareil pour sa deuxième entité.
// BD bundle
$entity['books']['bundles']['bd'] = array(
'label' => t('Bande dessinée'),
'admin' => array(
'path' => 'admin/person/%book_type',
'real path' => 'admin/books/bd',
'bundle argument' => 2,
'access arguments' => array('administer nodes')
)
);
Au final, le hook_entity_info() de Martine ressemble a ceci.
function books_entity_info() {
$entity = array();
$entity['books'] = array (
'label' => t('Book'),
// la table definie dans books.install
'base table' => 'books',
'uri callback' => 'books_uri',
// on peut y attacher des fields
'fieldable' => TRUE,
'entity keys' => array(
// utilisé par entity_load pour requeter la base table
'id' => 'bid' ,
// utilisé par field_attach api pour charger les fields attachés
'bundle' => 'type'
),
'bundle keys' => array('bundle' => 'type'),
'bundles' => array()
);
// Roman bundle
$entity['books']['bundles']['roman'] = array(
'label' => t('Roman'),
'admin' => array(
// le chemin defini dans books_menu
// Le Field api utilise ceci pour ajouter ses éléments MENU_LOCAL_TASK
// qui servent a administer et afficher les fields
'path' => 'admin/books/%book_type',
// le "real path" une fois l'argument chargé
'real path' => 'admin/books/roman',
// %books_type, c'est arg(2), donc on le passe ...
'bundle argument' => 2,
// on reste en "administer nodes" juste pour ne pas s'embeter avec les permissions :)
'access arguments' => array('administer nodes')
)
);
// BD bundle
$entity['books']['bundles']['bd'] = array(
'label' => t('Bande dessinée'),
'admin' => array(
'path' => 'admin/person/%book_type',
'real path' => 'admin/books/bd',
'bundle argument' => 2,
'access arguments' => array('administer nodes')
)
);
return $entity;
}
Il ne faut pas oublier de renseigner le books_uri défini plus haut permettant de définir le chemin canonique de l'entité.
function books_uri($books) {
return array('path' => 'books/' . $books->bid );
}
L'interface d'administration
Martine est contente, elle a son entité a elle toute seul. Néanmoins, il faut encore réaliser les pages d'administration, pour cette entité. Ca se passe donc du côté du hook_menu() cette fois-ci.
Martine va donc créer les entrées de menu pour la page d'administration générale, la page pour chaque bundle (%book_type etant passé en argument) où l'on va pouvoir gérer toute la partie ajout de Fields par exemple.
Pas oublier aussi la page permettant de créer une nouvelle entité :) (Martine va être contente)
$items['admin/books'] = array(
'title' => 'admin books',
'access arguments' => array('access content'),
'page callback' => 'books_admin',
'file' => 'books.pages.inc',
'type' => MENU_NORMAL_ITEM
);
// Pas d'admin des BooksType, utilisé par l'API de Fields
// pour attacher les fields aux bundles.
$items['admin/books/%book_type'] = array(
'title callback' => 'books_type_title',
'title arguments' => array(2),
'access arguments' => array('access content'),
'page arguments' => array(2),
);
// book default tab : add a book
$items['admin/books/%book_type/add'] = array(
'title' => 'add',
'access arguments' => array('access content'),
'type' => MENU_DEFAULT_LOCAL_TASK
);
Vous avez sans doute remarqué les "%book_type" qui trainent un peu partout dans les url. Il s'agit des "Wildcard Loader Arguments" (si vous avez une traduction pas trop nulle, je suis preneur). En gros, %nimportequoi fera automatiquement appelle au hook_load correspondant, c'est a dire ici %nimportequoi_load().
Martine doit donc ecrire son book_type_load().
Toujours pour des raisons de simplifier un peu l'ensemble, on reste sur nos deux Bundles hardcodé.
/**
* Argument loader for %book_type
* Verification de ce qu'on passe via %book_type afin d'eviter toute tentative d'injection.
*/
function book_type_load($books_type) {
switch ($books_type) {
case 'roman':
case 'bd':
return $books_type;
default:
return FALSE;
}
}
Il reste maintenant a écrire les "page callback" de notre hook_menu()
Martine écrit donc son books_type_title()
/**
* On retourne juste le type de l'object
*/
function books_type_title($books_type) {
return $books_type;
}
Et le books_admin() (attention, comme on peut le voir dans le hook_menu(), lui il se trouve dans le fichier books.pages.inc)
/**
* La page d'administration de nos Bundles Books
* Par defaut, on liste les bundles, si on clic sur un bundle,
* on obtiens la page d'administration du bundle en question.
* Il s'agit de la page où l'on va retrouver les tabs permettant
* de gerer les fields attaché et l'affichage de ceux-ci.
*/
function books_admin($type = NULL) {
if ($type) {
return drupal_get_form('books_addbook', $type);
}
else{
// pour faire plus simple, on hardcode les bundles existants.
$rows = array();
$rows[] = array(l(t('Roman'), 'admin/books/roman'));
$rows[] = array(l(t('Bd'), 'admin/books/bd'));
$header = array('Type');
$content = array(
'#theme' => 'table', // on veut un affichage tableau
'#header' => $header, // Un peu d'informations pour les headers, c'est joli
'#rows' => $rows, // et les links vers les 2 bundles existants
);
return $content;
}
}
En gros, si un "type" est présent dans l'url, on redirige vers le formulaire d'ajout d'entité, sinon, on se contente de lister les Bundles présents sur le site. Toujours hardcodé, simplicité, tout ça, vous commencez à comprendre je pense.
Et donc, a quoi va ressembler le books_addbook() de Martine ?
/**
* Ajout d'un nouveau book, du type selectionné.
*/
function books_addbook($form ,&$form_state, $type) {
$form['name'] = array(
'#type' => 'textfield',
'#title' => 'name'
);
$book = new stdClass();
$book->type = $type;
// l'object book lui même
$form['books'] = array(
'#type' => 'value',
'#value' => $book
);
// le type du bundle, indispensable pour passer la validation
$form['type'] = array(
'#type' => 'value',
'#value' => $type
);
// On attache les formulaires des fields attachés
field_attach_form('books',$book, $form, $form_state);
$form['actions'] = array('#type' => 'actions');
$form['actions']['add'] = array(
'#type' => 'submit',
'#value' => 'add'
);
return $form;
}
Ne pas oublier la fonction de validation.
Quel est l'utilité de cette fonction de validation ? Simplement, elle se contente de lancer la validation des fields attachés. (un field image n'acceptera que les images des extensions autorisées, un field "Entier" n'acceptera pas de nombres décimaux, etc...)
function books_addbook_validate($form, &$form_state) {
entity_form_field_validate('books', $form, $form_state);
}
Ainsi que le submit, parce que bon, il faut bien les enregistrer les valeurs que l'on vient de remplir.
function books_addbook_submit($form, &$form_state) {
$book = $form_state['values']['books'];
$book->name = $form_state['values']['name'];
// Enregistrement dans la base "books"
drupal_write_record('books', $book);
// L'objet est "rempli" avec les propriétés issues de form_state
entity_form_submit_build_entity('books', $book, $form, $form_state);
// Laissons aussi une chance à d'autres modules d'intervenir sur les Fields attachés.
field_attach_submit('books', $book, $form, $form_state);
// On insere les données des fields dans la base de données.
field_attach_insert('books', $book);
// Et un petit message de confirmation.
drupal_set_message(
t('new @type got added' ,
array('@type' => $book->type))
);
}
On devrait donc maintenant avoir une interface d'administration à cette url : http://exemple.com/admin/books
L'interface utilisateur
Les pages d'administration, c'est sympa, mais bon, Martine, elle veut quand même pouvoir afficher ses entités sur son site.
Pour commencer, on va donc rajouter les pages correspondantes au hook_menu()
$items['books/%books'] = array(
'title callback' => 'books_title',
'title arguments' => array(1),
'page callback' => 'books_display_one',
'page arguments' => array(1),
'access arguments' => array('access content'),
'file' => 'books.pages.inc',
'type' => MENU_CALLBACK
);
// Le tab "View"
$items['books/%books/view'] = array(
'title' => 'view',
'access arguments' => array('access content'),
'weight' => -3,
'type' => MENU_DEFAULT_LOCAL_TASK
);
// Le tab "Edit"
$items['books/%books/edit'] = array(
'title' => 'edit',
'page callback' => 'drupal_get_form',
'page arguments' => array( 'books_edit' , 1 ),
'access arguments' => array('access content'),
'file' => 'books.pages.inc',
'type' => MENU_LOCAL_TASK
);
Rien de bien compliqué ici. On définit les deux entrée de menu qui serviront pour afficher les tabs "View" et "Edit" sur la page de l'entité.
Sinon, je pense que le reste peut se passer de détails approfondis.
On voit un %book qui traine, alors hop, le hook_load() qui va avec.
/**
* Menu callback, argument loader for %books
*/
function books_load($bid) {
// entity_load() requete la table de base de l'entité et se
// charge éganelement de charger les champs attachés.
$books = entity_load('books', array($bid) );
return $books[$bid];
}
Sans oublier aussi le "title callback"
function books_title($books) {
return $books->name;
}
Martine va tout d'abord s'occuper de $items['books/%books'], donc du callback "books_display_one" qui au final est très simple :
function books_display_one($book) {
// l'objet "book" est déjà chargé via entity_load
$content[] = array(
'#markup' => l($book->name, 'books/' . $book->bid )
);
// on oublie pas d'attacher les fields supplémentaires.
$content[] = field_attach_view('books', $book ,'full');
return $content;
}
C'est tout pour lui.
Reste a s'occuper de $items['books/%books/edit'], donc du callback "books_edit", qui au final va être très très proche de books_addbook()
function books_edit($form, &$form_state, $book) {
// Affichage du nom du book
$form['name'] = array(
'#type' => 'textfield',
'#title' => 'name',
'#default_value' => $book->name
);
$form['books'] = array(
'#type' => 'value',
'#value' => $book
);
$form['type'] = array(
'#type' => 'value',
'#value' => $book->type
);
// On affiche les formulaires des fields attachés.
field_attach_form('books', $book, $form, $form_state );
// Sauvegarde
$form['actions'] = array('#type' => 'actions');
// le bouton de sauvegarde
$form['actions']['save'] = array(
'#type' => 'submit',
'#value' => 'save'
);
// On gere aussi la suppression
$form['actions']['delete'] = array(
'#type' => 'submit',
'#value' => 'delete',
'#submit' => array('books_edit_delete')
);
return $form;
}
Avec toujours la validation
function books_edit_validate($form, &$form_state) {
entity_form_field_validate('books', $form, $form_state);
}
Et le submit
function books_edit_submit($form, &$form_state) {
$book = $form_state['values']['books'];
$book->name = check_plain($form_state['values']['name']);
drupal_write_record('books', $book, array('bid'));
entity_form_submit_build_entity('books', $book, $form, $form_state);
field_attach_submit('books', $book, $form, $form_state);
field_attach_update('books', $book);
drupal_set_message(
t( 'the @type got saved' ,
array('@type' => $book->type) )
);
$form_state['redirect'] = 'books/' . $book->bid;
}
Légère différence avec ce que l'on trouve sur les pages d'administration, la fonction de suppression d'une entité.
Pas besoin de s'attarder dessus tellement c'est simple (surtout que Martine a mis plein de commentaires, elle n’est pas magnifique Martine hein ?)
/**
* Gestion de la suppresion d'un book
*/
function books_edit_delete($form, &$form_state) {
$book = $form_state['values']['books'];
// On supprime les info des fields attaché
field_attach_delete('books', $book);
// et on supprime aussi le book en lui-même.
db_delete('books')
->condition('bip', $book->bid)
->execute();
$form_state['redirect'] = 'books';
}
Intégration avec views
Martine est super contente, elle a son entité, mais ce serait le top du top si elle pouvoir utiliser les données de son entité directement dans Views. Et bien faisons ça !
La premiere étape est d'implémenter le hook_views_api(), qui reste toujours aussi court.
function books_views_api() {
return array(
'api' => 3,
'path' => drupal_get_path('module', 'books') . '/views',
);
}
L'implémentation de ce hook a pour conséquence que views qui essayer d'aller automagiquement chercher un fichier [module].views.inc dans le dossier [module]/views
Voici donc que Martine écrit le fichier books.views.inc et y implémente le hook_views_data(), qui permet de définir quels champs pour être exposé a Views.
function books_views_data() {
$data['books']['table']['group'] = t('Books');
$data['books']['table']['base'] = array(
'field' => 'bid',
'title' => t('Books'),
'help' => t('Books definitions.'),
);
$data['books']['bid'] = array(
'title' => t('Books ID'),
'help' => t('The unique internal identifier of the book.'),
'field' => array(
'handler' => 'views_handler_field_numeric',
'click sortable' => TRUE,
),
'filter' => array(
'handler' => 'views_handler_filter_numeric',
),
'sort' => array(
'handler' => 'views_handler_sort',
),
'argument' => array(
'handler' => 'views_handler_argument_numeric',
),
);
$data['books']['name'] = array(
'title' => t('Book Name'),
'help' => t('The name of the book.'),
'field' => array(
'handler' => 'views_handler_field',
'click sortable' => TRUE,
),
'filter' => array(
'handler' => 'views_handler_filter_string',
),
'sort' => array(
'handler' => 'views_handler_sort',
),
'argument' => array(
'handler' => 'views_handler_argument_string',
),
);
$data['books']['type'] = array(
'title' => t('Book Type'),
'help' => t('The type of the book.'),
'field' => array(
'handler' => 'views_handler_field',
'click sortable' => TRUE,
),
'filter' => array(
'handler' => 'views_handler_filter_string',
),
'sort' => array(
'handler' => 'views_handler_sort',
),
'argument' => array(
'handler' => 'views_handler_argument_string',
),
);
return $data;
}
Je n'irais pas trop dans les détails ici, ce n'est pas vraiment le but de cet exercice. Si vous ne comprenez pas tout ici, Martine vous invite a aller fouiller un peu la doc et l'API de Views. Néanmoins, quelques explications de bases :
$data['books']['bid'] / $data['books']['name'] / $data['books']['type']
sont nos 3 champs de la table 'books' de notre BDD.
Concernant la partie
$data['books']['table']['base'] = array(
'field' => 'bid',
'title' => t('Books'),
'help' => t('Books definitions.'),
);
Elle permet de créer un nouveau "type" de views, qui va voir pour table de base "books"
Enfin, pour finir, ce petit bout de code
$data['books']['table']['group'] = t('Books');
Permet lui, juste, de définir un groupe de champs.
Voila, l'ensemble des champs de notre "base table" de l'entité sont maintenant disponible dans views, et automagiquement, tous les fields que l'on aurait attaché à un des bundles de l'entité se retrouvent eux aussi exposé dans views.
Martine va pouvoir classement sa bibliothèque de la manière la plus geek possible qu'il soit maintenant.
Pour les fainéants du fond qui n'ont pas envie de passer leur temps a copier/coller du code, vous pouvez télécharger ce micro module.