12
\$\begingroup\$

I've been working on what is essentially my first proper attempt at making a useful PHP wrapper class for the Wordpress Settings API. It works great so far and I plan to add more methods as I go so that I eventually build up a class that can be used in pretty much any situation.

As this is my first proper PHP class, I'm looking for suggestions of ways that I could refactor or do things more efficiently. Perhaps places where I could write code differently/use different functions to improve it.

The code is also on my GitHub repo, so I'd appreciate any constructive feedback.

<?php
/**
 * A PHP Wrapper class to help set up an options page for the Wordpress API.
 *
 * @package Wordpress
 * @subpackage ThemeName
 * @version 1.0
 *
 */
class ThemeOptions
{
    // Declare class variables
    public $pages = array();
    public $sections = array();
    public $fields = array();

    /**
     * Construct
     *
     * @since 1.0
     */
    public function __construct()
    {   

        // Set the pages, sections and fields   
        add_action( 'admin_menu', array( &$this, 'addOptionsPages' ) );
        add_action( 'admin_init', array( &$this, 'initializeThemeOptions' ) );

    }

    /**
     * Add the options pages
     *
     * @since 1.0
     * @todo Add support for other menu types
     */
    public function addOptionsPages()
    {   

        foreach ( $this->pages as $page )
        {
            if ($page['type']=='menu')
            {
                add_menu_page(
                    __( $page['title'] ),
                    __( $page['title'] ),
                    'administrator',
                    $page['id'],
                    array( &$this, 'displayOptionsPage' )
                );
            }
            else if ($page['type'] == 'submenu')
            {
                add_submenu_page(
                    $page['parent'],
                    __( $page['title'] ),
                    __( $page['title'] ),
                    'administrator',
                    $page['id'],
                    array( &$this, 'displayOptionsPage' )
                );
            }
        }

    }

    /**
     * Add the settings sections
     *
     * @since 1.0
     */
    private function addOptionsSections()
    {
        foreach( $this->sections as $section )
        {
            add_settings_section(
                $section['id'],
                __($section['title']),
                '', // Add the description for the section in later
                $section['page']
            );
        }
    }


    /**
     * Add the settings fields
     *
     * @since 1.0
     */
    private function addOptionsFields()
    {
        foreach ($this->fields as $section => $field)
        {
            foreach ($field as $option)
            {
                add_settings_field(
                    $option['name'],
                    __($option['label']),
                    array(&$this, 'display' . ucfirst(strtolower($option['type'])) . 'Field'),
                    $section,
                    $option['section'],
                    array(
                        'option'        => $section,
                        'optionName'    => $option['name'] 
                    )
                );
            }
        }
    }

    /**
     * Register the settings with register_setting()
     *
     * @since 1.0
     */
    private function registerSettings()
    {
        foreach ($this->pages as $page)
        {
            register_setting(
                $page['id'],
                $page['id']
            );
        }
    }

    /**
     * Display options page
     *
     * @since 1.0
     */
    public function displayOptionsPage()
    {
        if (isset($_GET['tab']))
        {
            $activeTab = $_GET['tab'];
        }
        else
        {
            $activeTab = strtolower( str_replace('_', '-', $_GET['page']) );
        }
    ?>
        <div class="wrap">
            <div id="icon-themes" class="icon32"></div>
            <h2><?php _e( $this->pageTitle ); ?></h2>
            <?php
            settings_errors();
            $this->displayOptionsTabs( $activeTab );
            ?>
            <form method="post" action="options.php">
                <?php $this->displayOptionsSettings( $activeTab ); ?>
                <?php submit_button(); ?>
            </form>
        </div>
    <?php
    }

    /**
     * Set the pages, sections and fields
     *
     * @since 1.0
     */
    public function initializeThemeOptions()
    {
        $this->setOptionsDefaults();
        // Create the sections
        $this->addOptionsSections();
        // Create the fields
        $this->addOptionsFields(); 
        // Register the settings
        $this->registerSettings();

    }

    /**
     * Display the tabs
     *
     * @param string $activeTab;
     * @since 1.0
     */
    private function displayOptionsTabs( $activeTab = '' )
    {
        if ( count($this->pages) > 0 )
        {   

            $output = '<h2 class="nav-tab-wrapper">';

            foreach ( $this->pages as $page )
            {
                $currentTab = strtolower(str_replace('_', '-', $page['id']));
                $activeClass = $activeTab == $currentTab ? ' nav-tab-active' : '';

                $output .= sprintf(
                    '<a href="?page=%1$s&tab=%2$s" class="nav-tab%4$s" id="%2$s-tab">%3$s</a>',
                    $page['id'],
                    $currentTab,
                    $page['title'],
                    $activeClass
                );
            }

            $output .= '</h2>';
            echo $output;
        }
    }

    /**
     * Display the sections and fields according to the page
     *
     * @param string $activeTab;
     * @since 1.0
     */
    private function displayOptionsSettings( $activeTab = '' )
    {
        $currentTab = strtolower( str_replace('-', '_', $activeTab) );
        settings_fields( $currentTab );
        do_settings_sections( $currentTab );
    }

    /**
     * HTML output for text field
     *
     * @param array $option;
     * @since 1.0
     */
    public function displayTextField( $option = array() )
    {
        $value = get_option( $option['option'] );

        printf(
            '<input type="text" id="%1$s" class="regular-text" name="%2$s[%3$s]" value="%4$s">',
            str_replace('_', '-', $option['optionName']),
            $option['option'],
            $option['optionName'],
            sanitize_text_field($value[$option['optionName']])
        );

    }

    /**
     * HTML output for textarea field
     *
     * @param array $option;
     * @since 1.0
     */
    public function displayTextareaField( $option = array() )
    {
        $value = get_option( $option['option'] );

        printf(
            '<textarea id="%1$s" name="%2$s[%3$s]" rows="5" cols="60">%4$s</textarea>',
            str_replace('_', '-', $option['optionName']),
            $option['option'],
            $option['optionName'],
            esc_textarea($value[$option['optionName']])
        );

    }

    /**
     * HTML output for url field
     *
     * @param array $option;
     * @since 1.0
     */
    public function displayUrlField( $option = array() )
    {
        $value = get_option( $option['option'] );

        printf(
            '<input type="url" id="%1$s" class="regular-text" name="%2$s[%3$s]" value="%4$s">',
            str_replace('_', '-', $option['optionName']),
            $option['option'],
            $option['optionName'],
            esc_url($value[$option['optionName']])
        );
    }

    /**
     * Set default values for the settings.
     *
     * @since 1.0
     */
    private function setOptionsDefaults()
    {
        $defaults = array();

        foreach ($this->fields as $optionsName => $options)
        {
            $defaults[$optionsName] = array();

            foreach($options as $option)
            {
                $defaults[$optionsName][$option['name']] = '';
            }

        }
        foreach ($defaults as $option => $value)
        {
            if( get_option( $option ) == false )
            {
                add_option(
                    $option,
                    apply_filters( $option . '_defaults', $value )
                );
            }
        }

    }

    /**
     * Validate the input depending on it's type. Used in register_setting().
     *
     * @since 1.0
     * @todo Write the function!!!
     */
    public function validateOptions( $input )
    {
        return $input;
    }

}
\$\endgroup\$
2
  • 1
    \$\begingroup\$ Looks great to me. One thing I'd change, just to make it cleaner - IMO. In the displayOptionsPage() function $activeTab = isset($_GET['tab']) ? $_GET['tab'] : strtolower( str_replace('_', '-', $_GET['page']) ); \$\endgroup\$
    – Jack
    May 19, 2014 at 17:15
  • 1
    \$\begingroup\$ Yep, to me looks great too in code and interface. Hint: I always use this plugin to help building admin interfaces. \$\endgroup\$
    – brasofilo
    May 20, 2014 at 17:53

2 Answers 2

7
+100
\$\begingroup\$

The first small thing that pops out is array( &$this, 'method_name' ). That's PHP 4 and you can totally drop the & (WordPress requires PHP 5.2.4+).

The second is that if your going to use localization, better to put all in place throughout your code, ie: $translated = __( 'Hello World!', 'mytextdomain' );.

And finally, and maybe that's micro-optimization, it's said that === is faster than ==.

Notes regarding the code samples bellow:

Interface

I'd really consider moving the theme options to a second level menu. We tend to think that our product deserves the spotlight, but, from a users point of view, theme settings are something that you do once in a while, maybe only once. The first thing that I do when I setup a site is removing stuff from my first level menu, as I value that real estate a lot.

The method addOptionsPages() would be:

public function add_options_pages() { 
    foreach ( $this->pages as $page ) {
        if ( $page['type'] == 'menu' )
            add_theme_page( $args );
        else if ( $page['type'] == 'submenu' )
            add_submenu_page(
                null, # <-- Invisible page attached to /wp-admin/options.php?page=THIS-SUBMENU
                __( $page['title'], 'mytextdomain' ),
                __( $page['title'], 'mytextdomain' ),
                'administrator',
                $page['id'],
                array( $this, 'display_options_page' )
            );
    }
}

And adjust the method displayOptionsTabs() adding the full URL in %5$s:

$output .= sprintf(
    '<a href="%5$s?page=%1$s&tab=%2$s" class="nav-tab%4$s" id="%2$s-tab">%3$s</a>',
    $page['id'],
    $currentTab,
    $page['title'],
    $activeClass,
    ( $page['type'] == 'menu' ) ? admin_url( 'themes.php') : admin_url( 'options.php')
);

You'll note that there'll be an issue with the current admin menu indicator, it can be solved like this or this.

Given that all this ends in a jQuery fix, you can consider only add_theme_page() and deal with the Tabs display doing a switch( $_GET['tab'] ) {}.

Scripts and styles

To target our own pages, the add_(sub)menu_page functions return the hook name that we can use like:

$hook = add_menu_page( $args );
add_action( "admin_print_scripts-$hook", array( $this, 'print_scripts' );

public function print_scripts(){
    ?>
    <style> /* declare styles */ </style>
    <script>/* run scripts */ </style>
    <?php
}

And to enqueue, and using your code:

private $hooks = array();

public function add_options_pages() { 
    foreach ( $this->pages as $page ) {
        if ($page['type']=='menu')
            $this->hooks[] = add_theme_page( $args );
        else if ($page['type'] == 'submenu')
            $this->hooks[] = add_submenu_page( $args );
    }
    add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
}

public function enqueue( $hook ){
    if( !in_array( $hook, $this->hooks ) )
        return;
    wp_enqueue_script( 'my-scp', plugins_url( '/js/my-script.js', __FILE__) ); # Adjust to use with themes
    wp_enqueue_style( 'my-stl', plugins_url( '/css/my-style.js', __FILE__) ); 
}

Settings API

The only thing I'm missing is validation and sanitization in your register_setting( $option_group, $option_name, $sanitize_callback );. Two excellent references by leading WP developers:

  • WordPress Settings API Tutorial, by Otto Wood

    // validate our options
    function plugin_options_validate( $input ) {
        $newinput['text_string'] = trim( $input['text_string'] );
        if( !preg_match( '/^[a-z0-9]{32}$/i', $newinput['text_string'] ) )
            $newinput['text_string'] = '';
        return $newinput;
    }
    

    Take special note of the fact that I don’t return the original array. [...] In short, $input is untrusted data, but the returned $newinput should be trusted data.

  • The WordPress Settings API, by Konstantin Kovshenin

    function my_settings_validate( $input ) {
        $output = get_option( 'my-settings' );        
        if ( is_email( $input['email'] ) )
            $output['email'] = $input['email'];
        else
            add_settings_error( 'my-settings', 'invalid-email', 'You have entered an invalid e-mail address.' );        
        return $output;
    }
    

    Here’s the trick with validation. We’ll use the same technique as sanitization, but we will return the current option value, if the new input value was not good enough.

You'd probably have to add a validation type inside your $PBThemeOptions->fields;

Database

In setOptionsDefaults() method, you're saving one option for each item in the fields array (example-options.php in your repo):

$fields = array(
    // Example Section One
    'sandbox_theme_menu' => array( /* etc */ ),
    // Example Section Two
    'sandbox_theme_display_options' => array( /* etc */ )
);

Those are only two items, but as good practice it's recommended that we store all our plugin/theme settings inside one option only. Truth be told, as you have it it's much better than what we see in the wild, one db entry for each and every sub-option.

\$\endgroup\$
3
  • \$\begingroup\$ I prefer to use Braces even on One-Liner if statements, but other than that +1. \$\endgroup\$
    – Malachi
    May 20, 2014 at 17:37
  • \$\begingroup\$ Thanks for going through this @brasofilo, I appreciate the time and effort you've put into your answer. Understand your comments about the validation function too. Actually one of the reasons why I started work on this is because of the apparent lack of a validation function in other wrappers. \$\endgroup\$ May 20, 2014 at 17:47
  • \$\begingroup\$ @glitchmunki, at the end I saw that you have a @todo for validateOptions() but didn't mention it... \$\endgroup\$
    – brasofilo
    May 20, 2014 at 17:49
4
\$\begingroup\$

your first function should use a switch statement instead of a complex if then statement.

    foreach ( $this->pages as $page )
    {
        if ($page['type']=='menu')
        {
            add_menu_page(
                __( $page['title'] ),
                __( $page['title'] ),
                'administrator',
                $page['id'],
                array( &$this, 'displayOptionsPage' )
            );
        }
        else if ($page['type'] == 'submenu')
        {
            add_submenu_page(
                $page['parent'],
                __( $page['title'] ),
                __( $page['title'] ),
                'administrator',
                $page['id'],
                array( &$this, 'displayOptionsPage' )
            );
        }
    }

It would look like this:

    foreach ( $this->pages as $page )
    {
        switch ($page['type']) {
            case 'menu':
                add_menu_page(
                    __( $page['title'] ),
                    __( $page['title'] ),
                    'administrator',
                    $page['id'],
                    array( &$this, 'displayOptionsPage' )
                );
                break;
            case 'submenu':
                add_submenu_page(
                    $page['parent'],
                    __( $page['title'] ),
                    __( $page['title'] ),
                    'administrator',
                    $page['id'],
                    array( &$this, 'displayOptionsPage' )
                );
                break;
        }
    }

You might even be able to make the guts of these case statements into a function with a parameter.

By turning this into a switch it will make things a lot easier to maintain later.

You will also be able to add a default page here as well, so that if something weird happens you will always know what page they will end up on.

\$\endgroup\$
1
  • 2
    \$\begingroup\$ Thanks @Malachi, and really good point about the default case. It will definitely help with readability if I add more menu options. \$\endgroup\$ May 20, 2014 at 19:37

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.