Tutorial Plugin WordPress: Book Review

Introducerea a fost aici.

Repo se află aici.

Notă de început

Trebuie să menționez că o bună parte din cod este luat direct din Codex-ul WordPress. Scopul acestui articol nu este acela de a-ți oferi cod complet original ci acela de a-ți arăta un mod de lucru: ce să folosești, unde să cauți, cum să organizezi codul, cum să folosești clase șamd.

Nu uita, suntem pe forum ca să învățăm cu toții!

  • Dacă ai nelămuriri, întreabă, oricât de ridicolă ți s-ar părea întrebarea. Nu o să râdă nimeni de tine, nu o să te ia nimeni la mișto, nimeni nu s-a născut învățat.
  • Dacă observi vreo greșeală în codul meu sau dacă ai vreo idee mai bună de a face un anumit lucru, nu ezita să lași un comentariu!
  • Dacă ai de gând să faci miștouri, să glumești, să lași un comentariu offtopic, abtine-te sau comentează aici. :smile:

Primii Pași

Primul lucru pe care îl avem de făcut este să facem un fișier numit index.php în wp-content/plugins/book-review și să adăugăm minimum de informație pentru ca WordPress să vadă fișierul ca pe un plugin:

<?php //index.php

/*
Plugin Name: Book Review
Author: Ionuț Staicu
Version: 1.0.0
*/

În continuare va trebui să facem o verificare pentru a preveni accesarea directă a fișierului plugin-ului:

//index.php
if (!defined('ABSPATH')) {
  exit;
}

Localizare

Mai încărcăm și traducerile (un pas opțional dar util):

add_action('plugins_loaded', function () {
  load_plugin_textdomain('book-review', false, dirname(plugin_basename(__FILE__)) . '/lang');
});

Și am terminat ce era mai greu :slight_smile:

Facem o paranteză la traduceri pentru a menționa un lucru important ce poate scăpa ușor din vedere: numele fișierelor *.mo & *.po trebuie să urmeze următoarea structură: text-domain-lang_LANG. În cazul nostru vor fi nevoie de fișierele book-review-ro_RO.mo respectiv book-review-ro_RO.po. Aceste două fișiere vor fi plasate în directorul lang.

Pentru că vrem ca plugin-ul să fie ușor de tradus de oricine, vom folosi doar texte în limba engleză. Pentru traducere poți folosi Poeditor (web) sau Poedit (stand alone; versiunea gratuită funcționează foarte bine cu pattern-urile menționate aici)

Poți citi mai multe despre localizări aici

Încărcarea fișierelor

Pentru a include fișierele necesare, avem două posibilități: ori folosim un autoloader ori le includem manual. Pentru că vor fi doar câteva, nu are rost să ne complicăm, deci vom recurge la clasicul require.

Git

Inițializăm și facem primul commit

git init
git add .
git commit -am "Initial commit"

Taxonomii și Post Types

Prima idee avută de mine a fost să țin totul în meta data, dar mi-am dat seama că unele informații se pot repeta: editură, autor sau genul cărții. În plus, aș fi limitat la altele: de exemplu n-aș putea afișa foarte ușor o arhivă cu toate cărțile citite sau nu aș putea afișa un widget.

Prin urmare, folosim un post type pentru cărți numit books și taxonomii pentru editură, autor și gen. Restul de informații (anul apariției, ISBN, imagine etc) sunt destul de specifice fiecărei publicații. Anul ar putea fi pus într-o taxonomie, dar consider că ar încărca prea mult UI-ul și ar face mai dificilă o filtrare a publicațiilor apărute între două date.

Pentru că vom folosi numele taxonomiilor în mai multe locuri, vom defini constante:

//index.php

define('BOOK_POST_TYPE', 'book');

define('BOOK_TAX_GENRE', 'book_genre');
define('BOOK_TAX_AUTHOR', 'book_author');
define('BOOK_TAX_PUBLISHER', 'book_publisher');

Apoi le înregistrăm. Practic tot codul de mai jos este exemplul din documentație:

//inc/bookReview/PostTypes.php

<?php

namespace bookReview;

class PostTypes
{
  public function __construct()
  {
    $this->registerPostType();
    $this->registerGenre();
    $this->registerAuthor();
    $this->registerPublisher();
  }

  protected function registerPostType()
  {
    $labels = array(
      'name' => _x('Books', 'post type general name'),
      'singular_name' => _x('Book', 'post type singular name'),
      'menu_name' => _x('Books', 'admin menu'),
      'name_admin_bar' => _x('Book', 'add new on admin bar'),
      'add_new' => _x('Add New', 'book'),
      'add_new_item' => __('Add New Book'),
      'new_item' => __('New Book'),
      'edit_item' => __('Edit Book'),
      'view_item' => __('View Book'),
      'all_items' => __('All Books'),
      'search_items' => __('Search Books'),
      'parent_item_colon' => __('Parent Books:'),
      'not_found' => __('No books found.'),
      'not_found_in_trash' => __('No books found in Trash.'),
    );

    $args = array(
      'labels' => $labels,
      'public' => true,
      'publicly_queryable' => true,
      'show_ui' => true,
      'show_in_menu' => true,
      'query_var' => true,
      'rewrite' => array('slug' => 'book'),
      'capability_type' => 'post',
      'has_archive' => true,
      'hierarchical' => false,
      'supports' => array('title', 'editor', 'author', 'thumbnail'),
    );

    register_post_type(BOOK_POST_TYPE, $args);
  }

  protected function registerGenre()
  {
    $this->registerTaxonomy(array(
      'singular' => _x('Genre', 'taxonomy general name'),
      'plural' => _x('Genre', 'taxonomy general name'),
      'taxonomy' => BOOK_TAX_GENRE,
      'isHierarchical' => true,
    ));
  }

  protected function registerAuthor()
  {

    $this->registerTaxonomy(array(
      'singular' => _x('Writer', 'taxonomy general name'),
      'plural' => _x('Writers', 'taxonomy general name'),
      'taxonomy' => BOOK_TAX_AUTHOR,
      'isHierarchical' => false,
    ));
  }

  protected function registerPublisher()
  {
    $this->registerTaxonomy(array(
      'singular' => _x('Publisher', 'taxonomy general name'),
      'plural' => _x('Publishers', 'taxonomy general name'),
      'taxonomy' => BOOK_TAX_PUBLISHER,
      'isHierarchical' => false,
    ));
  }

  protected function registerTaxonomy($options)
  {
    $labels = array(
      'name' => $options['plural'],
      'singular_name' => $options['singular'],
      'search_items' => sprintf(__('Search %s'), $options['plural']),
      'all_items' => sprintf(__('All %s'), $options['plural']),
      'parent_item' => sprintf(__('Parent %s'), $options['singular']),
      'parent_item_colon' => sprintf(__('Parent %s:'), $options['singular']),
      'edit_item' => sprintf(__('Edit %s'), $options['singular']),
      'update_item' => sprintf(__('Update %s'), $options['singular']),
      'add_new_item' => sprintf(__('Add New %s'), $options['singular']),
      'new_item_name' => sprintf(__('New %s Name'), $options['singular']),
      'menu_name' => sprintf(__('%s'), $options['singular']),

      'popular_items' => sprintf(__('Popular %s'), $options['plural']),
      'separate_items_with_commas' => sprintf(__('Separate %s with commas'), $options['plural']),
      'add_or_remove_items' => sprintf(__('Add or remove %s'), $options['plural']),
      'choose_from_most_used' => sprintf(__('Choose from the most used %s'), $options['plural']),
      'not_found' => sprintf(__('No %s found.'), $options['plural']),
    );

    $args = array(
      'hierarchical' => $options['isHierarchical'],
      'labels' => $labels,
      'show_ui' => true,
      'show_admin_column' => true,
      'query_var' => true,
      'rewrite' => array('slug' => $options['slug']),
    );

    register_taxonomy($options['taxonomy'], BOOK_POST_TYPE, $args);
  }
}

Pentru că avem trei taxonomii ce au aceleași proprietăți, am făcut o metodă specială pentru a evita codul duplicat.

Genul cărții ne va permite să avem cărțile organizate într-o structură ierarhică (IT/Programare/Web/PHP). La autor și la editor nu este nevoie de așa ceva.

În index.php va trebui să includem clasa și să o instanțiem:

//index.php

require_once 'inc/bookReview/PostTypes.php';

add_action('init', function () {
  new bookReview\PostTypes;
});

register_activation_hook(__FILE__, function () {
  new bookReview\PostTypes;
  flush_rewrite_rules();
});

A doua instanțiere a clasei PostTypes se face pentru a permite rescrierea permalinks. Detalii aici

Git

git add .
git commit -am "Added post types & taxonomies"

Metabox

// inc/bookReview/Metabox.php

<?php

namespace bookReview;

class Metabox
{
  public function __construct()
  {
    add_action('add_meta_boxes', array($this, 'addMetaBox'));
    add_action('save_post', array($this, 'saveMeta'));
  }

  public function addMetaBox()
  {
    add_meta_box('book_properties', __('Book Properties'), array($this, 'displayMetaBox'), BOOK_POST_TYPE, 'advanced', 'high');
  }

  public function displayMetaBox($post)
  {
    wp_nonce_field('book-review-nonce', 'book-review-nonce');
    do_action('book-review/metabox/before-fields', $post);

    echo $this->addFields($post);

    do_action('book-review/metabox/after-fields', $post);
  }

  protected function addFields($post)
  {
    return implode("\n", $fields);
  }

  public function saveMeta($post_id)
  {
    if (!isset($_POST['book-review-nonce']) || !wp_verify_nonce($_POST['book-review-nonce'], 'book-review-nonce')) {
      return;
    }

    if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
      return;
    }

    if (isset($_POST['post_type']) && 'page' == $_POST['post_type']) {
      if (!current_user_can('edit_page', $post_id)) {
        return;
      }
    } else {
      if (!current_user_can('edit_post', $post_id)) {
        return;
      }
    }

    $this->saveFields($post_id);

    do_action('book-review/metabox/save', $post_id);
  }

  protected function saveFields($postID)
  {

  }
}

Facem o clasă ce adaugă un metabox pentru CPT-ul nostru, adaugă nonce-ul și pregătește terenul pentru adăugarea field-urilor necesare. În mare parte, și aici este codul luat tot din Codex.

După cum observi, am adăugat și câteva do_action pentru a permite adăugarea de conținut extra din alte plugin-uri sau din functions.php.

Git

git add .
git commit -am "Added metabox skeleton"

Câmpurile din Metabox

Pentru că o să tot adăugăm câmpuri, vom adăuga întâi o metodă ce ne va ajuta să generăm input-uri și textarea foarte ușor:

// inc/bookReview/Metabox.php

protected function getTextField($postID, $name, $label, $textarea = false)
{
  $value = get_post_meta($postID, $name, true);

  if ($textarea) {
    $field = sprintf('<textarea name="%2$s" id="%2$s" class="widefat">%1$s</textarea>', esc_textarea($value), $name );
  } else {
    $field = sprintf('<input type="text" name="%2$s" id="%2$s" value="%1$s" class="widefat">', esc_attr($value), $name );
  }

  return sprintf('<p><label for="%s">%s: %s</label></p>', $name, $label, $field);
}

După care vom adăuga field-urile în metoda addFields:

// inc/bookReview/Metabox.php @ addFields
$fields[] = $this->getTextField($post->ID, '_isbn', __('ISBN'));
$fields[] = $this->getTextField($post->ID, '_publish_year', __('Publish Year'));
$fields[] = $this->getTextField($post->ID, '_buy_book', __('Buying Links'), true););

Evident, nu ar trebui să uităm să adăugăm numele în metoda saveFields!:

// inc/bookReview/Metabox.php @ saveFields
update_post_meta($postID, '_isbn', sanitize_text_field($_POST['_isbn']));
update_post_meta($postID, '_publish_year', sanitize_text_field($_POST['_publish_year']));

update_post_meta($postID, '_buy_book', wp_kses($_POST['_buy_book']));

Ce nume folosești pentru metafields?

Probabil ai observat un underscore în fața fiecărui nume al field-urilor. Este așa deoarece nu vrem ca aceste meta data să fie vizibile sau editabile în afara plugin-ului (detalii aici).

Git

git commit -am "Added basic meta fields"

Rating & Progres

Pentru că rating-ul și progresul sunt niște chestii foarte fixe, vom adăuga o metodă asemănătoare cu getTextField dar care va genera un tag select:

// inc/bookReview/Metabox.php
protected function getSelectField($postID, $name, $label, Array $values)
{
  $storedValue = get_post_meta($postID, $name, true);

  $options = array();
  foreach ($values as $value => $text) {
    $options[] = sprintf('<option value="%1$s"%2$s>%3$s</option>', $value, selected($storedValue, $value, false), $text);
  }

  $field = sprintf('<select name="%1$s" id="%1$s" class="widefat">%2$s</select>', $name, implode("\n", $options));

  return sprintf('<p><label for="%s">%s: %s</label></p>', $name, $label, $field);
}

După care, în metoda addFields adăugăm:

// inc/bookReview/Metabox.php @ addFields
$fields[] = $this->getProgress($post->ID);
$fields[] = $this->getRating($post->ID);

Metoda getProgress va chema getSelectField:

// inc/bookReview/Metabox.php
protected function getProgress($postID)
{
  $values = apply_filters('book-review/metabox/progress-options', array(
    "list" => __('On My List'),
    "reading" => __('Currently Reading'),
    "read" => __('Read'),
  ));

  return $this->getSelectField($postID, '_book_progress', __('Book Progress'), $values);
}

Trecerea valorilor implicite prin apply_filters va permite adăugarea de opțiuni ori dintr-un alt plugin ori din functions.php.

Similar, avem și getRating:

// inc/bookReview/Metabox.php
protected function getRating($postID)
{
  $values = apply_filters('book-review/metabox/progress-options', array(
    -1 => __('- Pick One -'),
    1 => __('Bad'),
    2 => __('Meh'),
    3 => __('Mediocre'),
    4 => __('Pretty good'),
    5 => __('Awesome!'),
  ));

  return $this->getSelectField($postID, '_book_rating', __('Book Rating'), $values);
}

Nu ar trebui să uităm să salvăm toate aceste câmpuri (în metoda saveFields)::

// inc/bookReview/Metabox.php @ saveFields

update_post_meta($postID, '_book_progress', sanitize_text_field($_POST['_book_progress']));
update_post_meta($postID, '_book_rating', sanitize_text_field($_POST['_book_rating']));

Git

git commit -am "Added rating & book status meta fields"

Javascript

În mod normal, aș folosi pentru coperta cărții featured image. Dar pentru că vrem să învățăm lucruri, voi aborda cealaltă metodă: integrarea cu galeria WordPress-ului.

În plus, putem să extindem puțin toată povestea și să adăugăm mai multe imagini pentru o carte (copertă, câteva poze/scan-uri etc).

Va urma

9 Likes

4 posts were split to a new topic: Tutorial Plugin WordPress: Book Review (comentarii offtopic)

Good. 2 chestii :

  1. Pentru cei ce vor sa faca fork la repo-ul tau si sa faca improvments aici, ce code style ar trebui sa urmeze? Identarea cu 4 tabs, acolade pe linii noi etc?
  2. Ar fi frumos daca s-ar face si un wordpress demo unde se pot urmari update-urile cu fiecare commit :smile:
  • inca nu inteleg de ce la definirea de clase/metode pui acolada pe o noua linie iar in rest inline.
1 Like

Indent cu tab-uri a rămas setat de la ultimul proiect, următorul commit va include .editorconfig

Despre coding style folosit scrie aici.

1 Like

Mi-am dat seama prea târziu că ar fi mai util să afișez diff cu schimbările, nu secvențele complete de cod.

Încărcarea fișierelor statice

Poți include fișierele statice în mai multe feluri, dar WordPress are un singur mod corect¹: folosind wp_register_script/wp_register_stype în interiorul hook-ul wp_enqueue_scripts pentru paginile de frontend, respectiv hook-ul admin_enqueue_scripts pentru paginile de admin.

¹_Mai există și varianta wp_print_scripts/admin_print_scripts dar consider că în acest caz este un pic overkill_

Pentru că vrem ca pluginul nostru să fie ușor de tradus, vom folosi și wp_localize_script. Această funcție va genera un obiect Javascript accesibil global, astfel încât vom putea folosi în script-urile noastre ceva de genul book_review_i18n.uploaderTitle.

// index.php
add_action('admin_enqueue_scripts', function ($hook) {
    wp_register_script('book-review-fileUpload', plugin_dir_url(__FILE__) . 'assets/javascripts/fileUpload.js', array('jquery'), '1');
    wp_enqueue_script('book-review-fileUpload');

    wp_localize_script('book-review-fileUpload', 'book_review_i18n', array(
        'uploaderTitle' => __('Upload a book Cover'),
        'uploaderButton' => __('Use selected Image')
    ));
});

Galeria WordPress

De vreo doi-trei ani, WordPress a renunțat la modul vechi de administrare al imaginilor și s-a trecut la o aplicație Backbone care este destul de extensibilă. Noi vom avea nevoie doar de funcționalitate de bază: upload și selectarea fișierelor existente.

Pentru că jQuery din WordPress are modul de compatibilitate activat, vom folosi un document.ready cu $ trimis ca parametru la callback, astfel încât $ va fi disponibil.

Dacă nu facem asta, orice selector de jQuery va fi de de forma jQuery('div').

jQuery(document).ready(function($){
    // codul nostru
});

Înainte de a continua cu JS va trebui să adăugăm un trigger în clasa Metabox.php:

// inc/bookReview/Metabox.php @ addFields
$fields[] = $this->getImageUploader($post->ID);

Respectiv metoda getImageUploader:


// inc/bookReview/Metabox.php
protected function getImageUploader($postID)
{
    $value = get_post_meta($postID, '_book_cover', true);
    $field[] = sprintf('<input type="text" name="_book_cover" value="%s" class="js-bookCover">', esc_attr($value));
    $field[] = sprintf('<button class="js-uploadBookCover">%s</button>', __('Upload Book Cover'));

    return sprintf('<p>%s</p>', implode("\n", $field));
}

Să nu uităm să adăugăm și în metoda saveFields noul câmp:

// inc/bookReview/Metabox.php @ saveFields
update_post_meta($postID, '_book_cover', sanitize_text_field($_POST['_book_cover']));

Momentan nu adăugăm preview, ci doar pregătim terenul. De asemenea, input-ul în care vor fi stocate ID-urile imaginilor va fi, în final, de tip hidden, dar momentan avem nevoie să vedem cum funcționază, deci rămâne de tip text.

De asemenea, toate elementele ce le vom folosi din JS vor avea o clasă de genul js-* pentru a putea separa ușor lucrurile.

// assets/javascripts/fileUpload.js
var frame = wp.media({
    title : book_review_i18n.uploaderTitle,
    multiple : false,
    library : {
        type : 'image'
    },
    button : {
        text : book_review_i18n.uploaderButton
    }
});

$('.js-uploadBookCover').on('click', function(e){
    e.preventDefault();
    frame.open();
});

frame.on('close',function() {
    var attachments = frame.state().get('selection').toJSON();
    $('.js-bookCover').val(_.pluck(attachments, 'id')[0]);
});

În acest moment avem și un media manager cât de cât funcțional. Să punem totul în Git!

Git

git add .
git commit -am "Added media uploader"

Galeria WordPress - Îmbunătățiri

Sunt mai multe probleme cu file uploader-ul nostru:

  1. Dacă deschizi galeria și o închizi fără să selectezi nimic, se pierde valoarea stocată.
  2. După ce salvezi, dacă deschizi galeria din nou nu ai nici o imagine selectată;
// assets/javascripts/fileUpload.js

- $('.js-bookCover').val(_.pluck(attachments, 'id')[0]);
+
+ if(attachments.length){
+     $('.js-bookCover').val(_.pluck(attachments, 'id')[0]);
+ }

Asta a rezolvat prima problemă. Cum o rezolvăm pe a doua? Așa cum avem un event pentru close, avem un event și pentru open. Prin urmare:

// assets/javascripts/fileUpload.js

frame.on('open', function(){
    var selection = frame.state().get('selection');
    var id = $('.js-bookCover').val();
    attachment = wp.media.attachment(id);
    attachment.fetch();
    selection.add( attachment ? [ attachment ] : [] );
});

Git

Acum că am rezolvat problemele, să mai facem un commit:

git commit -am "Fixed media uploader issues"

Galeria WordPress - Și mai multe Îmbunătățiri!

Acum avem posibilitatea de a selecta o imagine, nu ar fi frumos să putem să:

  1. O vedem în admin?
  2. O ștergem :slight_smile:

Preview pentru imaginea proaspăt adăugată

Ajustăm un pic metoda getImageUploader, astfel încât să afișăm imaginea selectată și salvată. În plus, schimbăm și tipul input-ului din text în hidden:

//inc/bookReview/Metabox.php
protected function getImageUploader($postID)
{
  $value = get_post_meta($postID, '_book_cover', true);
- $field[] = sprintf('<input type="text" name="_book_cover" value="%s" class="js-bookCover">', esc_attr($value));
- $field[] = sprintf('<button class="js-uploadBookCover">%s</button>', __('Upload Book Cover'));
+
+ $attachmentPreview = '';
+ if (!empty($value)) {
+     $size = apply_filters('book-review/images/cover-size', 'thumbnail');
+     $attachmentPreview = wp_get_attachment_image($value, $size);
+ }
+
+ $field[] = sprintf('<input type="hidden" name="_book_cover" value="%s" class="js-bookCover">', esc_attr($value));
+ $field[] = sprintf('<span class="previewBookCover js-previewBookCover">%s</span>', $attachmentPreview);
+ $field[] = sprintf('<button class="button-secondary js-uploadBookCover">%s</button>', __('Upload Book Cover'));

Pentru a avea un preview funcțional și când se schimbă imaginea (deci nu doar la refresh) modificăm fileUpload.js astfel:

// assets/javascripts/fileUpload.js
   $('.js-bookCover').val(_.pluck(attachments, 'id')[0]);
+  var attachmentPreview = attachments[0].sizes.thumbnail;
+  var previewImage = $('<img />').attr({
+      src : attachmentPreview.url,
+      width : attachmentPreview.width,
+      height : attachmentPreview.height,
+  });
+
+  $('.js-previewBookCover').html(previewImage);

Git

git commit -am "Media upload will show preview"

Dimensiune dinamică a imaginii

În acest moment putem avea o dimensiune de preview când se încarcă paginii și o altă dimensiune când schimbăm imaginea. În configurația curentă nu e cazul, dar ce se întâmplă dacă un alt programator va folosi filtrul book-review/images/cover-size ?

Vom trimite această dimensiune și în fileUpload.js astfel încât schimbarea imaginii nu înseamnă și schimbarea dimensiunii de afișare.

Pentru asta, mutăm filtrul înaintea condiției, redenumim variabila astfel încât va avea un nume ceva mai sugestiv, după care trimitem dimensiunea ca parametru data-*:

inc/bookReview/Metabox.php
  $attachmentPreview = '';
+
+ $previewSize = apply_filters('book-review/images/cover-size', 'thumbnail');
  if (!empty($value)) {
-     $size = apply_filters('book-review/images/cover-size', 'thumbnail');
-     $attachmentPreview = wp_get_attachment_image($value, $size);
+     $attachmentPreview = wp_get_attachment_image($value, $previewSize);
  }

  $field[] = sprintf('<input type="hidden" name="_book_cover" value="%s" class="js-bookCover">', esc_attr($value));
- $field[] = sprintf('<span class="previewBookCover js-previewBookCover">%s</span>', $attachmentPreview);
+ $field[] = sprintf('<span class="previewBookCover js-previewBookCover" data-preview-size="%s">%s</span>', $previewSize, $attachmentPreview);

Pentru că se aglomerează situația din callback-ul close vom extrage tot ce ține de preview în funcția previewAttachment:

// assets/javascripts/fileUpload.js
if(attachments.length){
   $('.js-bookCover').val(_.pluck(attachments, 'id')[0]);
-  var attachmentPreview = attachments[0].sizes.thumbnail;
-  var previewImage = $('<img />').attr({
-      src : attachmentPreview.url,
-      width : attachmentPreview.width,
-      height : attachmentPreview.height,
-  });
-
-  $('.js-previewBookCover').html(previewImage);
+  previewAttachment(attachments[0]);
}
// assets/javascripts/fileUpload.js
+ function previewAttachment(attachment) {
+     var attachmentPreview = attachment.sizes.thumbnail;
+     var previewImage = $('<img />').attr({
+         src : attachmentPreview.url,
+         width : attachmentPreview.width,
+         height : attachmentPreview.height,
+     });
+
+     $('.js-previewBookCover').html(previewImage);
+ }

După care vom ține cont și de dimensiunea specificată în atributul data-preview-size:

// assets/javascripts/fileUpload.js
     function previewAttachment(attachment) {
-        var attachmentPreview = attachment.sizes.thumbnail;
+        var previewContainer = $('.js-previewBookCover');
+        var attachmentPreview = attachment.sizes[previewContainer.data('previewSize')];
+
         var previewImage = $('<img />').attr({
             src : attachmentPreview.url,
             width : attachmentPreview.width,
             height : attachmentPreview.height,
         });

-        $('.js-previewBookCover').html(previewImage);
+        previewContainer.html(previewImage);
     }

Git

git commit -am "Media upload will show preview at the right size"

Ștergerea imaginii

Pentru a șterge imaginea, va trebui să mai adăugăm un element în metoda getImageUploader și o clasă html:

// inc/bookReview/Metabox.php
         $field[] = sprintf('<span class="previewBookCover js-previewBookCover" data-preview-size="%s">%s</span>', $previewSize, $attachmentPreview);
+        $field[] = sprintf('<span class="deletePreviewBookCover js-deletePreviewBookCover">&times;</span>');

-        return sprintf('<p>%s</p>', implode("\n", $field));
+        $containerClassName = !empty($attachmentPreview) ? 'has-preview' : '';
+        return sprintf('<p class="%s">%s</p>', $containerClassName, implode("\n", $field));
     }

Adăugăm o clasă pentru a putea ascunde ulterior butonul de ștergere. Momentan scopul nu este acela de a avea elemente aspectuoase, dar asta nu inseamnă că nu putem pregăti terenul!

// assets/javascripts/fileUpload.js
         previewContainer.html(previewImage);
     }

+    $('.js-deletePreviewBookCover').on('click', function(e){
+        e.preventDefault();
+        $('.js-previewBookCover').empty();
+        $('.js-bookCover').val('');
+    });

Git

git commit -am "Media preview can now be removed"

Va urma…

2 Likes

Partea a treia: am terminat cu admin-ul cărților! Mâine construim metabox-ul pentru căutarea cărților!

Să stilizăm ce am făcut!

Până acum nu ne-a interesat foarte mult cum arată lucrurile; a fost mai important să le facem să funcționeze. Hai să facem aceste câmpuri să arate mai bine!

Planul este simplu:

  1. punem imaginea în partea din dreapta sus
  2. Facem restul câmpurilor ceva mai mici; majoritatea nici nu au nevoie să fie atât de late.

Întâi de toate, includem fișierul CSS. Așa cum am zis mai sus, putem include fișierele statice folosind wp_enqueue_style. Dacă tot suntem aici, ar trebui să definim o constantă cu versiunea plugin-ului, astfel încât la un update ulterior să facem un soi de cache busting la fișierele statice::

+++ b/index.php
@@ -14,6 +14,7 @@ add_action('plugins_loaded', function () {
     load_plugin_textdomain('book-review', false, dirname(plugin_basename(__FILE__)) . '/lang');
 });

+define('BOOK_VERSION', '1.0.0');
@@ -38,12 +39,15 @@ add_action('admin_init', function () {
 });

 add_action('admin_enqueue_scripts', function ($hook) {
-    wp_register_script('book-review-fileUpload', plugin_dir_url(__FILE__) . 'assets/javascripts/fileUpload.js', array('jquery'), '1');
+    wp_register_script('book-review-fileUpload', plugin_dir_url(__FILE__) . 'assets/javascripts/fileUpload.js', array('jquery'), BOOK_VERSION

     wp_localize_script('book-review-fileUpload', 'book_review_i18n', array(
         'uploaderTitle' => __('Upload a book Cover'),
-        'uploaderButton' => __('Use selected Image')
+        'uploaderButton' => __('Use selected Image'),
     ));

     wp_enqueue_script('book-review-fileUpload');
+
+    wp_register_style('book-review-fileUpload', plugin_dir_url(__FILE__) . 'assets/stylesheets/fileUpload.css', array(), BOOK_VERSION);
+    wp_enqueue_style('book-review-fileUpload');
 });

Apoi vom ajusta modul în care HTML-ul din metabox este generat:

inc/bookReview/Metabox.php
@@ -27,12 +27,15 @@ class Metabox

     protected f unction addFields($post)
     {
+        $fields[] = $this->getImageUploader($post->ID);
+
+        $fields[] = sprintf('<div class="previewBookFields" style="margin-right:%dpx">', ($this->getAttachmentSizeByName($this->getPreviewSize())['width'] + 20));
         $fields[] = $this->getTextField($post->ID, '_isbn', __('ISBN'));
         $fields[] = $this->getTextField($post->ID, '_publish_year', __('Publish Year'));
         $fields[] = $this->getTextField($post->ID, '_buy_book', __('Buying Links'), true);
         $fields[] = $this->getProgress($post->ID);
         $fields[] = $this->getRating($post->ID);
-        $fields[] = $this->getImageUploader($post->ID);
+        $fields[] = '</div>';

         return implode("\n", $fields);
     }
@@ -43,7 +46,7 @@ class Metabox

         $attachmentPreview = '';

-        $previewSize = apply_filters('book-review/images/cover-size', 'thumbnail');
+        $previewSize = $this->getPreviewSize();
         if (!empty($value)) {
             $attachmentPreview = wp_get_attachment_image($value, $previewSize);
         }
@@ -54,7 +57,16 @@ class Metabox
         $field[] = sprintf('<button class="button-secondary js-uploadBookCover">%s</button>', __('Upload Book Cover'));

         $containerClassName = !empty($attachmentPreview) ? 'has-preview' : '';
-        return sprintf('<p class="%s">%s</p>', $containerClassName, implode("\n", $field));
+        return sprintf('<div class="previewBookCoverContainer %s" style="width:%dpx">%s</div>',
+            $containerClassName,
+            $this->getAttachmentSizeByName($previewSize)['width'],
+            implode("\n", $field)
+        );
+    }
+
+    protected function getPreviewSize()
+    {
+        return apply_filters('book-review/images/cover-size', 'thumbnail');
     }

     protected function getProgress($postID)
@@ -144,4 +156,36 @@ class Metabox

         update_post_meta($postID, '_buy_book', wp_kses($_POST['_buy_book']));
     }
+
+    protected function getAttachmentSizeByName($size = '')
+    {
+        global $_wp_additional_image_sizes;
+
+        $sizes = array();
+        $get_intermediate_image_sizes = get_intermediate_image_sizes();
+
+        foreach ($get_intermediate_image_sizes as $_size) {
+            if (in_array($_size, array('thumbnail', 'medium', 'large'))) {
+                $sizes[$_size]['width'] = get_option($_size . '_size_w');
+                $sizes[$_size]['height'] = get_option($_size . '_size_h');
+                $sizes[$_size]['crop'] = (bool) get_option($_size . '_crop');
+            } elseif (isset($_wp_additional_image_sizes[$_size])) {
+                $sizes[$_size] = array(
+                    'width' => $_wp_additional_image_sizes[$_size]['width'],
+                    'height' => $_wp_additional_image_sizes[$_size]['height'],
+                    'crop' => $_wp_additional_image_sizes[$_size]['crop'],
+                );
+            }
+        }
+
+        if ($size) {
+            if (isset($sizes[$size])) {
+                return $sizes[$size];
+            } else {
+                return $this->getAttachmentSizeByName('thumbnail');
+            }
+        }
+
+        return $sizes;
+    }
 }
Ce se întâmplă?
  1. Adăugăm metoda getAttachmentSizeByName, care ne permite aflăm dimensiunile unui atașament. În cazul în care numele nu este valid, întoarcem dimensiunile pentru mărimea thumbnail (valorile pot fi schimbate în wp-admin -> settings -> Media).
  2. Punem imaginea de preview într-un element cu lățimea egală cu valoarea întoarsă de getAttachmentSizeByName.
  3. Punem restul câmpurilor într-un alt element căruia îi dăm un margin-right egal cu lățimea de mai sus plus 20px, pentru a nu fi elementele lipte unele de celelalte.

CSS

CSS-ul este destul de explicit:

assets/stylesheets/fileUpload.css
@@ -0,0 +1,32 @@
+.previewBookCoverContainer {
+    float:right;
+    position:relative;
+    text-align:center;;
+}
+
+.previewBookCoverContainer img {
+    margin:auto;
+    max-width:100%;
+    height:auto;
+}
+
+.deletePreviewBookCover {
+    position:absolute;
+    right:0;
+    top:0;
+    z-index:3;
+    cursor:pointer;
+    background:#fff;
+    color:#000;
+    padding:5px;
+    line-height:1;
+    opacity:0;
+    display:none;
+}
+
+.previewBookCoverContainer:hover .deletePreviewBookCover {
+    opacity:1;
+}
+
+.previewBookCoverContainer.has-preview > .deletePreviewBookCover {
+    display:block;
+}

Git

git add .
git commit -am "Added some styling"

În acest moment, butonul de ștergere este ușor buggy:

  1. Dacă ștergem o imagine, butonul continuă să apară;
  2. Când adăugăm o imagine pentru prima dată, butonul de ștergere nu apare deloc!

În primul rând, ar trebui să adăugăm o clasă în HTML-ul generat:

+++ b/inc/bookReview/Metabox.php
@@ -57,7 +57,7 @@ class Metabox
         $field[] = sprintf('<button class="button-secondary js-uploadBookCover">%s</button>', __('Upload Book Cover'));

         $containerClassName = !empty($attachmentPreview) ? 'has-preview' : '';
-        return sprintf('<div class="previewBookCoverContainer %s" style="width:%dpx">%s</div>',
+        return sprintf('<div class="previewBookCoverContainer js-previewBookCoverContainer %s" style="width:%dpx">%s</div>',
             $containerClassName,
             $this->getAttachmentSizeByName($previewSize)['width'],
             implode("\n", $field)

După care adăugăm adăugăm/ștergem clasa elementului, dar, de acaestă dată, din JS:

+++ b/assets/javascripts/fileUpload.js
@@ -32,6 +32,7 @@ jQuery(document).ready(function($){
         e.preventDefault();
         $('.js-previewBookCover').empty();
         $('.js-bookCover').val('');
+        $('.js-previewBookCoverContainer').removeClass('has-preview');
     });

     frame.on('open', function(){
@@ -46,6 +47,7 @@ jQuery(document).ready(function($){
         var attachments = frame.state().get('selection').toJSON();
         if(attachments.length){
             $('.js-bookCover').val(_.pluck(attachments, 'id')[0]);
+            $('.js-previewBookCoverContainer').addClass('has-preview');
             previewAttachment(attachments[0]);
         }
     });

Git

git commit -am "Fixed delete preview bugs"

Refactor

În fișierul JS avem o problemă: repetăm de câteva ori selectorii jQuery. Hai să reparăm acest lucru!

+++ b/assets/javascripts/fileUpload.js
@@ -10,14 +10,19 @@ jQuery(document).ready(function($){
         }
     });

+    var previewClassName = 'has-preview';
+
+    var previewContainer = $('.js-previewBookCoverContainer');
+    var previewCover = $('.js-previewBookCover');
+    var bookCover = $('.js-bookCover');
+
     $('.js-uploadBookCover').on('click', function(e){
         e.preventDefault();
         frame.open();
     });

     function previewAttachment(attachment) {
-        var previewContainer = $('.js-previewBookCover');
-        var attachmentPreview = attachment.sizes[previewContainer.data('previewSize')];
+        var attachmentPreview = attachment.sizes[previewCover.data('previewSize')];

         var previewImage = $('<img />').attr({
             src : attachmentPreview.url,
@@ -25,19 +30,19 @@ jQuery(document).ready(function($){
             height : attachmentPreview.height,
         });

-        previewContainer.html(previewImage);
+        previewCover.html(previewImage);
     }

     $('.js-deletePreviewBookCover').on('click', function(e){
         e.preventDefault();
-        $('.js-previewBookCover').empty();
-        $('.js-bookCover').val('');
-        $('.js-previewBookCoverContainer').removeClass('has-preview');
+        previewCover.empty();
+        bookCover.val('');
+        previewContainer.removeClass(previewClassName);
     });

     frame.on('open', function(){
         var selection = frame.state().get('selection');
-        var id = $('.js-bookCover').val();
+        var id = bookCover.val();
         attachment = wp.media.attachment(id);
         attachment.fetch();
         selection.add( attachment ? [ attachment ] : [] );
@@ -46,8 +51,8 @@ jQuery(document).ready(function($){
     frame.on('close',function() {
         var attachments = frame.state().get('selection').toJSON();
         if(attachments.length){
-            $('.js-bookCover').val(_.pluck(attachments, 'id')[0]);
-            $('.js-previewBookCoverContainer').addClass('has-preview');
+            bookCover.val(_.pluck(attachments, 'id')[0]);
+            previewContainer.addClass(previewClassName);
             previewAttachment(attachments[0]);
         }
     });

După cum observi, nu am făcut decât să mutăm toți selectorii la început, astfel încât avem parte și de caching și nici nu încălcăm principiul DRY.

Git

git commit -am "Refactored JS"

Va urma

1 Like

Partea a patra: să facem un widget (prima parte)

Din păcate azi a fost o zi și mai scurtă decât de obicei, prin urmare, abia am avut timp să scriu câteva linii: avem un bugfix și primii pași în crearea unui widget.

Bugfix

Fără să-mi dau seama, am făcut în așa fel încât fișierul fileUpload.js va strica lucrurile pe alte pagini (în cele în care wp.media nu este disponibil). Un fix rapid arată așa:

--- a/assets/javascripts/fileUpload.js
+++ b/assets/javascripts/fileUpload.js
@@ -1,4 +1,5 @@
 jQuery(document).ready(function($){
+    if(typeof wp.media == 'undefined') {return;}

Poate că într-o versiune viitoare vor face o încărcare selectivă a fișierului, dar având în vedere dimensiunea script-ului… nu ne facem probleme.

Git

git commit -am "Fixed JS error"

Pentru că partea de adăugare de cărți este completă, putem trece la următoarele componente: un widget și un shortcode ce ne vor permite să adăugăm oriunde în site o listă cu ce am citit, o listă cu ce vrem să citim, o listă cu cele mai bune cărți etc.

Atât shortcode-ul cât și widget-ul vor permite următoarele:

Sortare
  • Rating
  • Anul apariției
  • Data la care ai terminat de citit cartea (va trebui să adăugăm un nou field pentru asta în clasa Metabox)
Altele
  • Afișarea cărților în funcție de status (citit/în curs de citire/etc);
  • Opțiune pentru a afișa/ascunde anumite elemente: ISBN, imagine, dată, taxonomii etc;

Widget-ul

Introducere

Pentru a înregistra un widget custom, trebuie să extindem clasa WP_Widget. Noua clasă va avea patru metode absolut obligatorii:

  • __construct - unde specificăm detaliile widget-ului (nume, ID, descriere);
  • widget($args, $instance) - afișarea widget-ului în frontend;
  • form($instance) - afișarea widget-ului în backend;
  • update($new_instance, $old_instance) - metoda apelată în momentul în care se editează și se salvează widget-ul.

Să trecem la treabă:

Întâi de toate, vom include fișierul Widget.php în index.php, după care vom notifica WordPress-ul de existența unui nou widget:

@@ -22,6 +22,7 @@ define('BOOK_TAX_AUTHOR', 'book_author');
 define('BOOK_TAX_PUBLISHER', 'book_publisher');

 require_once 'inc/bookReview/PostTypes.php';
+require_once 'inc/bookReview/BookReviewWidget.php';

 add_action('init', function () {
     new bookReview\PostTypes;
@@ -51,3 +52,7 @@ add_action('admin_enqueue_scripts', function ($hook) {
     wp_register_style('book-review-fileUpload', plugin_dir_url(__FILE__) . 'assets/stylesheets/fileUpload.css', array(), BOOK_VERSION);
     wp_enqueue_style('book-review-fileUpload');
 });
+
+add_action('widgets_init', function () {
+    register_widget('bookReview\BookReviewWidget');
+});

În fișierul inc/bookReview/Widget.php construim o clasă cu metodele mai sus menționate:

<?php
// inc/bookReview/Widget.php

namespace bookReview;

class BookReviewWidget extends \WP_Widget
{
    public function __construct()
    {
        parent::__construct('book_review_widget', __('Book Review'),
            array('description' => __('A book widget')));
    }
    public function widget($args, $instance)
    {
    }
    public function form($instance)
    {
    }
    public function update($new_instance, $old_instance)
    {
    }
}

Git

git add .
git commit -am "Added basic widget"

Pentru că nu putem afișa widget-ul pe frontend fără a avea niște opțiuni, vom implementa întâi partea de admin (metoda form).

Începem prin a adăuga titlul widget-ului, dar pentru că vor fi destul de multe câmpuri, vom avea câte o metodă pentru fiecare. Începem cu titlul:

+++ b/inc/bookReview/BookReviewWidget.php
@@ -14,8 +14,16 @@ class BookReviewWidget extends \WP_Widget
     }
     public function form($instance)
     {
+        echo $this->getTitleField($instance);
     }
     public function update($new_instance, $old_instance)
     {
     }
+
+    protected function getTitleField($instance)
+    {
+        $title = !empty($instance['title']) ? $instance['title'] : '';
+        return sprintf('<p><label for="%1$s">%2$s</label><input type="text" name="%1$s" value="%3$s" class="widefat"></p>',
+            $this->get_field_id('title'), __('Title'), $title);
+    }
 }

După care adăugăm și opțiunile de sortare:

+++ b/inc/bookReview/BookReviewWidget.php
@@ -15,6 +15,7 @@ class BookReviewWidget extends \WP_Widget
     public function form($instance)
     {
         echo $this->getTitleField($instance);
+        echo $this->getSortField($instance);
     }
     public function update($new_instance, $old_instance)
     {
@@ -26,4 +27,35 @@ class BookReviewWidget extends \WP_Widget
         return sprintf('<p><label for="%1$s">%2$s</label><input type="text" name="%1$s" value="%3$s" class="widefat"></p>',
             $this->get_field_id('title'), __('Title'), $title);
     }
+
+    protected function getSortField($instance)
+    {
+        $select = '';
+        $sortByOptions = apply_filters('book-review/widget/sortby-options', array(
+            'finished' => __('Date you\'ve finished the book'),
+            'added' => __('Date you\'ve added the book'),
+        ));
+
+        $sortby = !empty($instance['sortby']) ? $instance['sortby'] : 'finished';
+        $select[] = sprintf('<p><label for="%1$s">%2$s </label><select class="widefat" name="%1$s">',
+            $this->get_field_id('sortby'), __('Sort By:'));
+
+        foreach ($sortByOptions as $value => $text) {
+            $select[] = sprintf('<option value="%s"%s>%s</option>',
+                $value, selected($value, $sortby, false), $text);
+        }
+
+        $select[] = '</select></p>';
+
+        $sort = !empty($instance['sort']) ? $instance['sort'] : 'DESC';
+        $select[] = sprintf('<p><label for="%1$s">%2$s </label><select class="widefat" name="%1$s">',
+            $this->get_field_id('sort'), __('Sort:'));
+
+        $select[] = sprintf('<option value="ASC"%s>%s</option>', selected('ASC', $sort, false), __('ASC'));
+        $select[] = sprintf('<option value="DESC"%s>%s</option>', selected('DESC', $sort, false), __('DESC'));
+
+        $select[] = '</select></p>';
+
+        return implode("\n", $select);
+    }
 }

Git

git commit -am "Added basic widget controls"

Un mic refactor

Pentru că valorile default vor fi folosite în trei locuri, ar fi bine să le punem într-un array, proprietate a clasei:

+++ b/inc/bookReview/BookReviewWidget.php
@@ -4,6 +4,12 @@ namespace bookReview;

 class BookReviewWidget extends \WP_Widget
 {
+    protected $defaultValues = array(
+        'title' => '',
+        'sortby' => 'finished',
+        'sort' => 'DESC',
+    );
+
     public function __construct()
     {
         parent::__construct('book_review_widget', __('Book Review'),
@@ -23,7 +29,7 @@ class BookReviewWidget extends \WP_Widget

     protected function getTitleField($instance)
     {
-        $title = !empty($instance['title']) ? $instance['title'] : '';
+        $title = !empty($instance['title']) ? $instance['title'] : $this->defaultValues['title'];
         return sprintf('<p><label for="%1$s">%2$s</label><input type="text" name="%1$s" value="%3$s" class="widefat"></p>',
             $this->get_field_id('title'), __('Title'), $title);
     }
@@ -36,7 +42,7 @@ class BookReviewWidget extends \WP_Widget
             'added' => __('Date you\'ve added the book'),
         ));

-        $sortby = !empty($instance['sortby']) ? $instance['sortby'] : 'finished';
+        $sortby = !empty($instance['sortby']) ? $instance['sortby'] : $this->defaultValues['sortby'];
         $select[] = sprintf('<p><label for="%1$s">%2$s </label><select class="widefat" name="%1$s">',
             $this->get_field_id('sortby'), __('Sort By:'));

@@ -47,7 +53,7 @@ class BookReviewWidget extends \WP_Widget

         $select[] = '</select></p>';

-        $sort = !empty($instance['sort']) ? $instance['sort'] : 'DESC';
+        $sort = !empty($instance['sort']) ? $instance['sort'] : $this->defaultValues['sort'];
         $select[] = sprintf('<p><label for="%1$s">%2$s </label><select class="widefat" name="%1$s">',
             $this->get_field_id('sort'), __('Sort:'));

Git

git commit -am "DRYing things a bit"

Partea a cincea: să facem un widget (partea a doua)

Nu uita, există și un repository pentru plugin, unde găsești cea mai recentă versiune funcțională!

Opțiuni suplimentare în Widget: ce elemente sunt vizibile?

Întâi de toate, am observat o greșeală destul de mare, în urma căreia widgetul nu era salvat: în loc de $this->get_field_name eu apelam $this->get_field_id. Evident că lucrurile nu mergeau!

Avem un diff de peste 100 linii, așa că îl voi sparege în bucăți și voi comenta unde e cazul:

+++ b/inc/bookReview/BookReviewWidget.php
@@ -8,10 +8,14 @@ class BookReviewWidget extends \WP_Widget
         'title' => '',
         'sortby' => 'finished',
         'sort' => 'DESC',
+        'widgetWasSaved' => 1,
+        'displayOptions' => array(),
     );

     public function __construct()
     {
+        $this->defaultValues['displayOptions'] = $this->getDefaultDisplayOptions();
+
         parent::__construct('book_review_widget', __('Book Review'),
             array('description' => __('A book widget')));
     }
  1. În primul rând vom avea nevoie să știm dacă widgetul a fost salvat vreodată sau este nou (pentru a ști cum bifăm checkbox-urile mai târziu)
  2. În al doilea rând, punem toate câmpurile ce vor fi afișate într-o metodă (pentru a nu aglomera __construct, pentru a putea fi extinsă mai târziu printr-un filtru etc)
@@ -22,19 +26,22 @@ class BookReviewWidget extends \WP_Widget

     public function form($instance)
     {
+        printf('<input type="hidden" name="%s" value="1">', $this->get_field_name('widgetWasSaved'));
         echo $this->getTitleField($instance);
         echo $this->getSortField($instance);
+        echo $this->getDisplayOptionsField($instance);
     }

     public function update($new_instance, $old_instance)
     {
+        return $new_instance;
     }
  1. Adăugăm câmpul care ne va ajuta să ne dăm seama de starea widgetului (nou sau nu);
  2. Afișăm bifele ce ne vor permite să alegem ce anume va fi vizibil și ce nu;
  3. Momentan nu facem nici un fel de validare, deci metoda update rămâne la nivelul basic.
     protected function getTitleField($instance)
     {
         $title = !empty($instance['title']) ? $instance['title'] : $this->defaultValues['title'];
-        return sprintf('<p><label for="%1$s">%2$s</label><input type="text" name="%1$s" value="%3$s" class="widefat"></p>',
-            $this->get_field_id('title'), __('Title'), $title);
+        return sprintf('<p><label>%2$s</label><input type="text" name="%1$s" value="%3$s" class="widefat"></p>',
+            $this->get_field_name('title'), __('Title'), esc_attr($title));
     }

Așa cum am menționat la început, am schimbat $this->get_field_id cu $this->get_field_name.

     protected function getSortField($instance)
@@ -46,8 +53,8 @@ class BookReviewWidget extends \WP_Widget
         ));

         $sortby = !empty($instance['sortby']) ? $instance['sortby'] : $this->defaultValues['sortby'];
-        $select[] = sprintf('<p><label for="%1$s">%2$s </label><select class="widefat" name="%1$s">',
-            $this->get_field_id('sortby'), __('Sort By:'));
+        $select[] = sprintf('<p><label>%2$s </label><select class="widefat" name="%1$s">',
+            $this->get_field_name('sortby'), __('Sort By:'));

         foreach ($sortByOptions as $value => $text) {
             $select[] = sprintf('<option value="%s"%s>%s</option>',
@@ -57,8 +64,8 @@ class BookReviewWidget extends \WP_Widget
         $select[] = '</select></p>';

         $sort = !empty($instance['sort']) ? $instance['sort'] : $this->defaultValues['sort'];
-        $select[] = sprintf('<p><label for="%1$s">%2$s </label><select class="widefat" name="%1$s">',
-            $this->get_field_id('sort'), __('Sort:'));
+        $select[] = sprintf('<p><label>%2$s </label><select class="widefat" name="%1$s">',
+            $this->get_field_name('sort'), __('Sort:'));

         $select[] = sprintf('<option value="ASC"%s>%s</option>', selected('ASC', $sort, false), __('ASC'));
         $select[] = sprintf('<option value="DESC"%s>%s</option>', selected('DESC', $sort, false), __('DESC'));
@@ -67,4 +74,40 @@ class BookReviewWidget extends \WP_Widget

         return implode("\n", $select);
     }
+
+    protected function getDisplayOptionsField($instance)
+    {
+        $defaultDisplayOptions = $this->widgetWasSaved($instance) ? array() : array_keys($this->defaultValues['displayOptions']);
+        $displayOptions = !empty($instance['displayOptions']) ? $instance['displayOptions'] : $defaultDisplayOptions;
+
+        foreach ($this->defaultValues['displayOptions'] as $key => $text) {
+            $fields[] = sprintf(' <label><input type="checkbox" name="%1$s[]" value="%2$s" %3$s> %4$s</label>',
+                $this->get_field_name('displayOptions'),
+                $key,
+                in_array($key, $displayOptions) ? ' checked' : '',
+                $text
+            );
+        }
+
+        return sprintf('<p>%s</p>', implode("<br>", $fields));
+    }
+

În mod implicit avem mai multe câmpuri ce pot fi afișate (noi le vom afișa pe toate). Dacă widgetul a fost salvat (vezi metoda de mai jos) atunci toate câmpurile pot fi deselectate (și vom afișa doar titlul cărții).

+    protected function widgetWasSaved($instance)
+    {
+        return !empty($instance['widgetWasSaved']) && $instance['widgetWasSaved'] == 1;
+    }
+
+    protected function getDefaultDisplayOptions()
+    {
+        return array(
+            'tax_' . BOOK_TAX_GENRE => __('Genre'),
+            'tax_' . BOOK_TAX_AUTHOR => __('Book Author'),
+            'tax_' . BOOK_TAX_PUBLISHER => __('Book Publisher'),
+            'thumb' => __('Thumbnail'),
+            'added_on' => __('Date you added the book'),
+            'started_on' => __('Date you started to read'),
+            'finished_on' => __('Date you finished the book'),
+            'year' => __('The year the book was published'),
+            'isbn' => __('ISBN'),
+        );
+    }
 }

Git

git commit -am "Added display options to widget; fixed save"

A mai rămas o singură opțiune de care am cam uitat: câte cărți putem afișa în widget?!

+++ b/inc/bookReview/BookReviewWidget.php
@@ -7,6 +7,7 @@ class BookReviewWidget extends \WP_Widget
     protected $defaultValues = array(
         'title' => '',
         'sortby' => 'finished',
+        'limit' => 1,
         'sort' => 'DESC',
         'widgetWasSaved' => 1,
         'displayOptions' => array(),
@@ -29,6 +30,7 @@ class BookReviewWidget extends \WP_Widget
         printf('<input type="hidden" name="%s" value="1">', $this->get_field_name('widgetWasSaved'));
         echo $this->getTitleField($instance);
         echo $this->getSortField($instance);
+        echo $this->getLimitField($instance);
         echo $this->getDisplayOptionsField($instance);
     }

@@ -44,6 +46,13 @@ class BookReviewWidget extends \WP_Widget
             $this->get_field_name('title'), __('Title'), esc_attr($title));
     }

+    protected function getLimitField($instance)
+    {
+        $limit = !empty($instance['limit']) ? $instance['limit'] : $this->defaultValues['limit'];
+        return sprintf('<p><label>%2$s</label><input type="number" min="1" step="1" name="%1$s" value="%3$s" class="widefat"></p>',
+            $this->get_field_name('limit'), __('Limit'), esc_attr($limit));
+    }
+
     protected function getSortField($instance)

Git

git commit -am "Added Book limit on widget"

Refactor

Pentru că tot repetăm verificările pentru câmpuri (și pentru că va trebui să folosim aceleași verificări iar în metoda widget), m-am gândit că ar fi mai bine să extragem logica într-o metodă getValue ce ne va permite să specificăm (ca parametri):

  1. Instanța widget-ului;
  2. Numele valorii (key);
  3. Valoarea default;
+++ b/inc/bookReview/BookReviewWidget.php
@@ -41,14 +41,14 @@ class BookReviewWidget extends \WP_Widget

     protected function getTitleField($instance)
     {
-        $title = !empty($instance['title']) ? $instance['title'] : $this->defaultValues['title'];
+        $title = $this->getValue($instance, 'title');
         return sprintf('<p><label>%2$s</label><input type="text" name="%1$s" value="%3$s" class="widefat"></p>',
             $this->get_field_name('title'), __('Title'), esc_attr($title));
     }

     protected function getLimitField($instance)
     {
-        $limit = !empty($instance['limit']) ? $instance['limit'] : $this->defaultValues['limit'];
+        $limit = $this->getValue($instance, 'limit');
         return sprintf('<p><label>%2$s</label><input type="number" min="1" step="1" name="%1$s" value="%3$s" class="widefat"></p>',
             $this->get_field_name('limit'), __('Limit'), esc_attr($limit));
     }
@@ -61,7 +61,7 @@ class BookReviewWidget extends \WP_Widget
             'added' => __('Date you\'ve added the book'),
         ));

-        $sortby = !empty($instance['sortby']) ? $instance['sortby'] : $this->defaultValues['sortby'];
+        $sortby = $this->getValue($instance, 'sortby');
         $select[] = sprintf('<p><label>%2$s </label><select class="widefat" name="%1$s">',
             $this->get_field_name('sortby'), __('Sort By:'));

@@ -86,8 +86,8 @@ class BookReviewWidget extends \WP_Widget

     protected function getDisplayOptionsField($instance)
     {
-        $defaultDisplayOptions = $this->widgetWasSaved($instance) ? array() : array_keys($this->defaultValues['displayOptions']);
-        $displayOptions = !empty($instance['displayOptions']) ? $instance['displayOptions'] : $defaultDisplayOptions;
+        $default = $this->widgetWasSaved($instance) ? array() : array_keys($this->defaultValues['displayOptions']);
+        $displayOptions = $this->getValue($instance, 'displayOptions', $default);

         foreach ($this->defaultValues['displayOptions'] as $key => $text) {
             $fields[] = sprintf(' <label><input type="checkbox" name="%1$s[]" value="%2$s" %3$s> %4$s</label>',
@@ -106,6 +106,12 @@ class BookReviewWidget extends \WP_Widget
         return !empty($instance['widgetWasSaved']) && $instance['widgetWasSaved'] == 1;
     }

+    protected function getValue($instance, $key, $default = null)
+    {
+        $default = !is_null($default) ? $default : $this->defaultValues[$key];
+        return !empty($instance[$key]) ? $instance[$key] : $default;
+    }
+
     protected function getDefaultDisplayOptions()
     {
         return array(

Git

git commit -am "DRY-ing things up"

Afișarea widget-ului în fronted

Pentru a afișa widget-ul în frontend lucrurile vor fi destul de simple: facem un query, un loop și îi dăm bătaie!

Varianta de bază - care arată doar titlurile cărților - este cam așa:

+++ b/inc/bookReview/BookReviewWidget.php
@@ -23,6 +23,37 @@ class BookReviewWidget extends \WP_Widget

     public function widget($args, $instance)
     {
+        echo $args['before_widget'];
+
+        if (false !== ($title = $this->getValue($instance, 'title')) && !empty($title)) {
+            echo $args['before_title'] . apply_filters('widget_title', $title) . $args['after_title'];
+        }
+
+        $books = new \WP_Query();
+
+        $books->query(array(
+            "post_type" => BOOK_POST_TYPE,
+        ));
+
+        $li = array();
+        if ($books->have_posts()) {
+            while ($books->have_posts()) {
+                $books->the_post();
+                $bookId = get_the_ID();
+
+                $elements = array();
+                $elements[] = get_the_title($bookId);
+
+                $li[] = sprintf('<li>%s</li>', implode("\n", $elements));
+            }
+            wp_reset_query();
+        } else {
+
+        }
+
+        printf('<ul>%s</ul>', implode("\n", $li));
+        echo $args['after_widget'];
     }

     public function form($instance)

Git

git commit -am "Added book listing on frontend"

Ce urmează?

Pentru că nu am făcut cel mai detaliat plan, au apărut câteva probleme. Printre altele:

  • Nu putem filtra (în widget) tipul cărților (citite/în curs de cititre);
  • Adăugarea acestor filtre în widget înseamnă duplicarea unor bucăți de cod din Metabox.php;
  • Adăugarea unei cărți noi nu permite setarea datelor (e.g. data când o carte a fost terminată poate fi sau nu aceeași cu data la care a fost adăugată cartea);

Prin urmare, vom face următoarele:

  • Vom folosi un array gol pentru book-review/metabox/progress-options;
  • Vom avea o nouă clasă ce se va ocupa doar cu injectarea valorilor; nu va avea logică (aproape) deloc.

Adițional, iau în considerare și să:

  • Adaug Handlebars sau Mustache (pentru a separa HTML-ul de PHP);
  • Adaug autoload pentru clase;
  • Adaug task-uri Grunt (sau Gulp?) pentru JS/SCSS

După o scurtă pauză, am revenit cu un mic refactor. Azi începem să folosim template-uri handlebar. Yay!

Refactor

Pentru că vrem să separăm PHP de HTML vom folosi Handlebars. Și pentru că Handlebars este instalabil (și) prin Composer, vom folosi… ei bine, Composer.

Pentru asta va trebui să deschidem o consolă (cmd/terminal, în funcție de platformă) și să executăm composer init. Urmărim instrucțiunile, iar la dependințe adăugăm xamin/handlebars.php. Restul instrucțiunilor sunt cele default.

Edităm composer.json și adăugăm următoarea cheie pentru a spune autoloader-ului în ce director ar trebui să caute bibliotecile:

"autoload": {
  "psr-4": {
    "bookReview\\": "inc/bookReview"
  }
}

Executăm și composer update pentru a instala tot ce este necesar și am terminat pasul ăsta.

În index.php facem următoarele modificări:

+++ b/index.php
@@ -21,8 +21,7 @@ define('BOOK_TAX_GENRE', 'book_genre');
 define('BOOK_TAX_AUTHOR', 'book_author');
 define('BOOK_TAX_PUBLISHER', 'book_publisher');

-require_once 'inc/bookReview/PostTypes.php';
-require_once 'inc/bookReview/BookReviewWidget.php';
+require_once 'vendor/autoload.php';

 add_action('init', function () {
     new bookReview\PostTypes;
@@ -33,8 +32,6 @@ register_activation_hook(__FILE__, function () {
     flush_rewrite_rules();
 });

-require_once 'inc/bookReview/Metabox.php';
-
 add_action('admin_init', function () {
     new bookReview\Metabox();
 });

Și am terminat de instalat autoloader.

Git

git add .
git commit -am "Added composer autoload"

Câteva cuvinte despre Handlebars

Handlebars este un sistem de templating cross-language. La fel ca și Mustache, este destul de dumb pentru a evita logică complexă în views, dar, spre deosebire de Mustache, este mai intuitiv și mai curat.

De exemplu, în Mustache, indiferent că vrei să verifici o condiție sau să iterezi un loop, vei folosi {{#foo}}...{{/foo}}. Handlebars, pe de altă parte, este un pic mai explicit: {{if foo}}...{{/if}} (evident, există directive și pentru condiții). Sunt atât de asemănătoare încât până la un punct, Mustache și Handlebars sunt compatibile, fiind interschimbabile.

Un avantaj al unui template Handlebars sau Mustache este că același cod poate fi folosit și în PHP și în JavaScript, existând un parser cam pentru fiecare limbaj cât de cât popular.

Noi îl vom folosi strict pentru a separa codul HTML de PHP, fără să implicăm alte limbaje.


Următorul pas ar fi să începem să folosim direct Handlebars. Având în vedere că nu vrem să fim blocați cu acest motor, vom folosi o clasă intermediară:

<?php

namespace bookReview;

use Handlebars\Handlebars;

class Tpl
{
    public static function get($tplName, $data = array())
    {
        $viewsOptions = apply_filters('book-review/template/options', array('extension' => '.hbs'));
        $partialOptions = apply_filters('book-review/template/partials-options', $viewsOptions);

        $tplPath = apply_filters('book-review/template/views-path', null);
        $partialPath = apply_filters('book-review/template/partials-path', null);

        $engine = new Handlebars(array(
            'loader' => new \Handlebars\Loader\FilesystemLoader($tplPath, $viewsOptions),
            'partials_loader' => new \Handlebars\Loader\FilesystemLoader($partialPath, $partialOptions),
        ));

        return $engine->render($tplName, $data);
    }
}

Asta ne va permite să afișăm un template foarte simplu: echo Tpl::get('sidebar')

De asemenea, va trebui să adăugăm două filtre în index.php, pentru a specifica calea unde vor fi păstrate template-urile:

+++ b/index.php
@@ -23,6 +23,14 @@ define('BOOK_TAX_PUBLISHER', 'book_publisher');

 require_once 'vendor/autoload.php';

+add_filter('book-review/template/views-path', function () {
+    return __DIR__ . '/views/';
+});
+
+add_filter('book-review/template/partials-path', function () {
+    return __DIR__ . '/views/partials/';
+});
+

Evident, ne vom asigura că folderele există: views respectiv views/partials există.

Git

git add .
git commit -am "Added Handlebars helper"

Refactor - Templating

Vom începe și de această dată tot cu Metabox.php:


+++ b/inc/bookReview/Metabox.php
@@ -2,6 +2,8 @@

 namespace bookReview;

+use \bookReview\Tpl;
+
 class Metabox
 {
     public function __construct()
@@ -27,17 +29,18 @@ class Metabox

     protected function addFields($post)
     {
-        $fields[] = $this->getImageUploader($post->ID);

-        $fields[] = sprintf('<div class="previewBookFields" style="margin-right:%dpx">', ($this->getAttachmentSizeByName($this->getPreviewSize())['width'] + 20));
         $fields[] = $this->getTextField($post->ID, '_isbn', __('ISBN'));
         $fields[] = $this->getTextField($post->ID, '_publish_year', __('Publish Year'));
         $fields[] = $this->getTextField($post->ID, '_buy_book', __('Buying Links'), true);
         $fields[] = $this->getProgress($post->ID);
         $fields[] = $this->getRating($post->ID);
-        $fields[] = '</div>';

-        return implode("\n", $fields);
+        return Tpl::get('metabox/previewBookFields', array(
+            'fields' => $fields,
+            'uploader' => $this->getImageUploader($post->ID),
+            'marginAdjust' => ($this->getAttachmentSizeByName($this->getPreviewSize())['width'] + 20)
+        ));
     }

Următorul pas este metoda getImageUploader:

+++ b/inc/bookReview/Metabox.php
@@ -54,18 +54,14 @@ class Metabox
             $attachmentPreview = wp_get_attachment_image($value, $previewSize);
         }

-        $field[] = sprintf('<input type="hidden" name="_book_cover" value="%s" class="js-bookCover">', esc_attr($value));
-        $field[] = sprintf('<span class="previewBookCover js-previewBookCover" data-preview-size="%s">%s</span>', $previewSize, $attachmentPreview); -        $field[] = sprintf('<span class="deletePreviewBookCover js-deletePreviewBookCover">&times;</span>');
-        $field[] = sprintf('<button class="button-secondary js-uploadBookCover">%s</button>', __('Upload Book Cover'));
-
-        $containerClassName = !empty($attachmentPreview) ? 'has-preview' : '';
-
-        return sprintf('<div class="previewBookCoverContainer js-previewBookCoverContainer %s" style="width:%dpx">%s</div>',
-            $containerClassName,
-            $this->getAttachmentSizeByName($previewSize)['width'],
-            implode("\n", $field)
-        );
+        return Tpl::get('metabox/previewBookCoverContainer', array(
+            'width' => $this->getAttachmentSizeByName($previewSize)['width'],
+            'value' =>  esc_attr($value),
+            'preview' => $attachmentPreview,
+            'hasPreview' => !empty($attachmentPreview),
+            'previewSize' => $previewSize,
+            'uploadAnchor' => __('Upload Book Cover')
+        ));
     }

Urmează elementele de formular:

+++ b/inc/bookReview/Metabox.php
@@ -112,13 +112,23 @@ class Metabox
     {
         $value = get_post_meta($postID, $name, true);

+        $tplData = array(
+            'name' => $name,
+            'id' => $name,
+            'value' => $textarea ? esc_textarea($value) : esc_attr($value),
+        );
+
         if ($textarea) {
-            $field = sprintf('<textarea name="%2$s" id="%2$s" class="widefat">%1$s</textarea>', esc_textarea($value), $name);
+            $field = Tpl::get('formFields/textarea', $tplData);
         } else {
-            $field = sprintf('<input type="text" name="%2$s" id="%2$s" value="%1$s" class="widefat">', esc_attr($value), $name);
+            $field = Tpl::get('formFields/text', $tplData);
         }

-        return sprintf('<p><label for="%s">%s: %s</label></p>', $name, $label, $field);
+        return Tpl::get('formFields/fieldWrapper', array(
+            'labelFor' => $name,
+            'label' => $label,
+            'field' => $field,
+        ));
     }


Respectiv `select`:

```diff
+++ b/inc/bookReview/Metabox.php
@@ -100,12 +100,25 @@ class Metabox

         $options = array();
         foreach ($values as $value => $text) {
-            $options[] = sprintf('<option value="%1$s"%2$s>%3$s</option>', $value, selected($storedValue, $value, false), $text);
+            $options[] = Tpl::get('formFields/option', array(
+                'value' => $value,
+                'text' => $text,
+                'selected' => selected($storedValue, $value, false),
+            ));
         }

-        $field = sprintf('<select name="%1$s" id="%1$s" class="widefat">%2$s</select>', $name, implode("\n", $options));
+        $field = Tpl::get('formFields/select', array(
+            'name' => $name,
+            'id' => $name,
+            'options' => $options,
+        ));

-        return sprintf('<p><label for="%s">%s: %s</label></p>', $name, $label, $field);
+        return Tpl::get('formFields/fieldWrapper', array(
+            'labelFor' => $name,
+            'label' => $label,
+            'field' => $field,
+        ));
     }

Git

git add .
git commit -am "Refactored Metabox to use templates"

Refactor widgets

Pentru că am folosit HTML și în clasa responsabilă de widgets, vom face și aici puțină curățenie:

În primul rând înlocuim tot ce generează textfield sau select:

+++ b/inc/bookReview/BookReviewWidget.php
@@ -71,45 +71,73 @@ class BookReviewWidget extends \WP_Widget

     protected function getTitleField($instance)
     {
-        $title = $this->getValue($instance, 'title');
-        return sprintf('<p><label>%2$s</label><input type="text" name="%1$s" value="%3$s" class="widefat"></p>',
-            $this->get_field_name('title'), __('Title'), esc_attr($title));
+        return $this->getTextField($instance, 'title', __('Titile'));
     }

     protected function getLimitField($instance)
     {
-        $limit = $this->getValue($instance, 'limit');
-        return sprintf('<p><label>%2$s</label><input type="number" min="1" step="1" name="%1$s" value="%3$s" class="widefat"></p>',
-            $this->get_field_name('limit'), __('Limit'), esc_attr($limit));
+        return $this->getTextField($instance, 'limit', __('Limit'), 'number', array(
+            'min' => "1",
+            'step' => "1",
+        ));
     }

-    protected function getSortField($instance)
+    protected function getTextField($instance, $key, $label, $inputType = 'text', $extraAttrs = array())
     {
-        $select = '';
-        $sortByOptions = apply_filters('book-review/widget/sortby-options', array(
-            'finished' => __('Date you\'ve finished the book'),
-            'added' => __('Date you\'ve added the book'),
+        $value = $this->getValue($instance, $key);
+
+        $field = Tpl::get('formFields/' . $inputType, array_merge(array(
+            'name' => $this->get_field_name($key),
+            'id' => $this->get_field_id($key),
+            'value' => esc_attr($value),
+        ), $extraAttrs));
+
+        return Tpl::get('formFields/fieldWrapper', array(
+            'labelFor' => $this->get_field_id($key),
+            'label' => $label,
+            'field' => $field,
         ));
+    }

-        $sortby = $this->getValue($instance, 'sortby');
-        $select[] = sprintf('<p><label>%2$s </label><select class="widefat" name="%1$s">',
-            $this->get_field_name('sortby'), __('Sort By:'));
-
-        foreach ($sortByOptions as $value => $text) {
-            $select[] = sprintf('<option value="%s"%s>%s</option>',
-                $value, selected($value, $sortby, false), $text);
+    protected function getSelect($instance, $key, $label, $values)
+    {
+        $storedValue = $this->getValue($instance, $key);
+
+        foreach ($values as $value => $text) {
+            $options[] = Tpl::get('formFields/option', array(
+                'value' => $value,
+                'text' => $text,
+                'selected' => selected($storedValue, $value, false),
+            ));
         }

-        $select[] = '</select></p>';
+        $field = Tpl::get('formFields/select', array(
+            'name' => $name,
+            'id' => $name,
+            'options' => $options,
+        ));

-        $sort = !empty($instance['sort']) ? $instance['sort'] : $this->defaultValues['sort'];
-        $select[] = sprintf('<p><label>%2$s </label><select class="widefat" name="%1$s">',
-            $this->get_field_name('sort'), __('Sort:'));
+        return Tpl::get('formFields/fieldWrapper', array(
+            'labelFor' => $name,
+            'label' => $label,
+            'field' => $field,
+        ));
+    }

-        $select[] = sprintf('<option value="ASC"%s>%s</option>', selected('ASC', $sort, false), __('ASC'));
-        $select[] = sprintf('<option value="DESC"%s>%s</option>', selected('DESC', $sort, false), __('DESC'));
+    protected function getSortField($instance)
+    {
+        $sortByOptions = apply_filters('book-review/widget/sortby-options', array(
+            'finished' => __('Date you\'ve finished the book'),
+            'added' => __('Date you\'ve added the book'),
+        ));
+
+        $sortOptions = array(
+            'ASC' => __('ASC'),
+            'DESC' => __('DESC'),
+        );

-        $select[] = '</select></p>';
+        $select[] = $this->getSelect($instance, 'sortby', __('Sort By'), $sortByOptions);
+        $select[] = $this->getSelect($instance, 'sort', __('Sort'), $sortOptions);

         return implode("\n", $select);
     }

De asemenea, înlocuim template-ul text.hbs cu ceva mai generic:

+++ b/views/formFields/text.hbs
@@ -1 +1 @@
-<input type="text" name="{{{ name }}}" id="{{{ id }}}" value="{{{ value }}}" class="widefat">
+{{> ../formFields/input this type='text'}}

Astfel încât vom putea genera cam orice fel de input field.

Mai avem încă o metodă de curățat: getDisplayOptionsField:

+++ b/inc/bookReview/BookReviewWidget.php
@@ -112,13 +112,13 @@ class BookReviewWidget extends \WP_Widget
         }

         $field = Tpl::get('formFields/select', array(
-            'name' => $name,
-            'id' => $name,
+            'name' => $this->get_field_name($key),
+            'id' => $this->get_field_id($key),
             'options' => $options,
         ));

         return Tpl::get('formFields/fieldWrapper', array(
-            'labelFor' => $name,
+            'labelFor' => $this->get_field_name($key),
             'label' => $label,
             'field' => $field,
         ));
@@ -148,15 +148,18 @@ class BookReviewWidget extends \WP_Widget
         $displayOptions = $this->getValue($instance, 'displayOptions', $default);

         foreach ($this->defaultValues['displayOptions'] as $key => $text) {
-            $fields[] = sprintf(' <label><input type="checkbox" name="%1$s[]" value="%2$s" %3$s> %4$s</label>',
-                $this->get_field_name('displayOptions'),
-                $key,
-                in_array($key, $displayOptions) ? ' checked' : '',
-                $text
-            );
+            $fields[] = $field = Tpl::get('formFields/checkbox', array(
+                'name' => $this->get_field_name('displayOptions'),
+                'value' => $key,
+                'id' => $this->get_field_id('displayOptions'),
+                'label' => $text,
+                'checked' => in_array($key, $displayOptions),
+            ));
         }

-        return sprintf('<p>%s</p>', implode("<br>", $fields));
+        return Tpl::get('formFields/checkboxWrapper', array(
+            'checkboxes' => $fields
+        ));
     }

     protected function widgetWasSaved($instance)

Git

git add .
git commit -am "Refactored Widgets to use templates"