Skip to content

Tutorial Custom Manager Page

Everett Griffiths edited this page Feb 23, 2015 · 4 revisions

In this tutorial, we'll use Repoman to create a MODX Extra that adds a custom menu page to the MODX manager.

Difficulties

Before we dive in, be aware that MODX manager controllers are not as flexible or intuitive as many of us would like. They are much improved in MODX 2.3, but it's still a bit of a rough ride for new and experienced developers alike.

The other stumbling block is ExtJS: it's not required that you use ExtJS, but the core classes that we'll be extending here are designed more-or-less expecting ExtJS to be used. Repoman doesn't care one way or another, but the learning curve and lack of transparency in ExtJS make it difficult for many users and this page does not demonstrate its use. For simplicity straight-up PHP is demonstrated instead.


Requirements

To build a CMP in MODX, we need only 3 things:

  • An Action Object
  • A Menu Object (which references the action)
  • A controller (a PHP class that responds to the manager request)

Repoman handles the action and menu objects as seed data; the controller class is where we encounter some friction with the MODX conventions, but we've outlined a workflow for you below.

Action Object

You can define your action object for your Repoman package as [Seed-Data]. By default, this means you will create the following file: model/seeds/modAction.php

<?php
return array (
    'namespace' => 'yournamespace',
    'controller' => 'index',
    'haslayout' => 1,
    'lang_topics' => 'yournamespace:default',
    'assets' => '',
    'help_url' =>  '',
);
?>

Note that the "namespace" here is the MODX namespace (NOT a PHP namespace). The index controller is also customizeable.

Menu Object

The menu object for your Repoman package is also defined as [Seed-Data]. By default, this means you will create the following file: model/seeds/modMenu.php

<?php
return array(
    array(
        'text' => 'My Text', // can be lexicon key
        'description' => 'My description', // can be lexicon key
        'parent' => 'components',
        'action' => 'index',
        'icon' => '',
        'menuindex' => 0,
        'params' => '',
        'handler' => '',
        'permissions' => '',
        'namespace' => 'yournamespace'
    ),
);
?>

The "text" and "description" items can be lexicon keys.

The link between the menu and the action is defined by the menu's action and namespace parameters. It used to be handled by a foreign key, but this way makes it a bit easier to insert and update data because there's no possibility of errors due to missing keys.

Controllers

Now that you have an action and a menu item where that action can be called, the last thing you need is a controller class that will handle the request when that action is called. The name and location of the file and class is unfortunately not very flexible: it's a hard-coded convention inside the MODX code. But here's the file we need to create: controllers/default/index.class.php

Inside the index.class.php must be a class name that follows the pattern {Namespace}{Action}ManagerController.

For example, if the MODX namespace for your package is "mynamespace" and your action is "index", your classname should be MynamespaceIndexManagerController. If you use an action other than "index", your filename should be changed as well. Just remember the following:

  • Your file name should be lowercase.
  • PHP classnames are NOT case-sensitive, but don't get sloppy: use camel casing here.

Your file should contain something like this:

<?php
// Uncomment the "require" line if you are using composer.
// We Gotta do this here because we don't have a reliable event for this. 
// require_once dirname(dirname(dirname(__FILE__))) . '/vendor/autoload.php';
class MynamespaceIndexManagerController extends \modExtraManagerController {
    /**
     * Do any page-specific logic and/or processing here
     * @param array $scriptProperties
     * @return void
     */
    public function process(array $scriptProperties = array())
    {
        return 'Hello!';
    }
}
?>

If you are using Composer, then you can uncomment the line that includes your autoload.php. The process function is what handles returning output. If all goes well here, you should be able to run php repoman install path/to/your/package, then clear the cache in the MODX manager and you should see a new menu item under "Extras", and when you click it, you should see the output from your process function.

Resist the temptation to add namespaces! If you are being a good PHP developer, you may want to use PHP namespaces on all your class files, but this is one place where you must follow the MODX convention, otherwise MODX won't be able to find the class. Later we'll show you how to transition to namespaced controllers via some elementary routing.

You have now created a "primary controller". The file is a PHP class, and it may be the only controller file that you need (e.g. for an "about" page), but frequently this file simply hands off to another PHP class inside of your package's controllers/ directory.


Routing

Once you have a primary controller in place, a common task is route requests to other controllers or functions. This is where you may want to leverage your PHP namespaces and come up with elegant and testable controllers. MODX is supposed to "listen" for the &action parameter in your URLs, but in practice, I found this either didn't work well, or wasn't as flexible as I would like.

Primary Controller to define Routes

One way to establish some rudimentary routing controls is to implement the getInstance() function in your primary controller and then use it to "point" to a different controller. For example, let's say we want &page=help to be handled by our PageController.php class and &page=about to be handled by our AboutController.php class.

Here's one way to do that. Update your index.class.php to handle the routing:

<?php
// Gotta do this here because we don't have a reliable event for this. 
require_once dirname(dirname(dirname(__FILE__))) . '/vendor/autoload.php';
class MynamespaceIndexManagerController extends \modExtraManagerController {

    public static $routing_key = 'page';

    /**
     * @static
     *
     * @param modX $modx A reference to the modX object.
     * @param string $className The name of the class that is being requested.
     * @param array $config A configuration array of options related to this controller's action object.
     *
     * @return The class specified by $className
     */
    public static function getInstance(\modX &$modx, $className, array $config = array()) {
        // Manual routing
        $className = (isset($_GET[self::$routing_key])) ? '\\Mynamespace\\'.ucfirst($_GET[self::$routing_key]).'Controller': '\\Mynamespace\\IndexController';
        unset($_GET[self::$routing_key]);
        /** @var modManagerController $controller */
        $controller = new $className($modx,$config);
        return $controller;
    }

}
/*EOF*/

The above example assumes that all of your other controller classes will use a PHP namespace of Mynamespace. Things are a little sloppy in there because we're reading unfiltered variables out of the $_GET array, and we need to do some manual string preparation of the $className to pre-pend the namespace, but at the end of the day, we instantiate a controller via something like new \Mynamespace\IndexController($modx,$config); -- we just need to use double-backslashes when we concatenate strings.

You may want to add a try/catch block there in case someone passes an invalid &page argument.

Secondary Controllers

Once your primary controller is returning an instance of one of your secondary controllers, you can structure your secondary controllers using PHP namespaces. You can cut down on some of the repetition by moving some of these functions to a base class which you extend -- really the primary functions your modx controller class should implement are:

  • getPageTitle
  • initialize
  • process

But here's an example of how your class might look:

<?php
namespace Mynamespace;

class IndexController extends \modExtraManagerController {

    // One place where you can load a custom style-sheet for your manager pages is in the __construct():
    function __construct(\modX &$modx,$config = array()) {
        parent::__construct($modx,$config);
        $this->config['core_path'] = $this->modx->getOption('mynamespace.core_path', null, MODX_CORE_PATH.'components/mynamespace/');
        $this->config['assets_url'] = $this->modx->getOption('mynamespace.assets_url', null, MODX_ASSETS_URL.'components/mynamespace/');

        $this->modx->regClientCSS($this->config['assets_url'] . 'css/mgr.css');
    }
    /**
     * Defines the lexicon topics to load in our controller.
     * @return array
     */
    public function getLanguageTopics() {
        return array('mynamespace:default');
    }

    /**
     * Override parent function.
     * Override Smarty. I don't wants it. But BEWARE: the loadHeader and loadFooter bits require
     * the functionality of the original fetchTemplate function.  ARRRGH.  You try to escape but you can't.
     *
     * @param string $file (relative to the views directory)
     * @return rendered string (e.g. HTML)
     */
    public function fetchTemplate($file) {
        // Conditional override! Gross! 
        // If we don't give Smarty a free pass, we end up with "View file does not exist" errors because
        // MODX relies on the parent fetchTemplate function to load up its header.tpl and footer.tpl files. Ick.
        if (substr($file,-4) == '.tpl') {
            return parent::fetchTemplate($file);
        }
        $path = $this->modx->getOption('mynamespace.core_path','', MODX_CORE_PATH.'components/mynamespace/').'views/';

        if (!is_file($path.$file)) {
            return $this->modx->lexicon('view_not_found', array('file'=> 'views/'.$file));
        }

        ob_start();

        include $path.$file;

        $content = ob_get_clean();

        return $content;
    }

    /**
     * The page title for this controller
     * @return string The string title of the page
     */
    public function getPageTitle()
    {
        return $this->modx->lexicon('some_lexicon_key');
    }

    /**
     * $this->scriptProperties will contain $_GET and $_POST stuff
     */
    public function initialize()
    {
        // Do any pre-processing stuff, e.g. $this->setPlaceholder()
    }

    /**
     * Do any page-specific logic and/or processing here
     * @param array $scriptProperties
     * @return void
     */
    public function process(array $scriptProperties = array())
    {
        return $this->fetchTemplate('index.php');
    }

}
?>

I'm forcefully bypassing any use of Smarty by overriding the fetchTemplate function in favor of simple PHP views. The goal was to have a controller that implemented functions like this:

    public function initialize()
    {
        $this->setPlaceholder('something', 'Some value!');
    }

    // ... 

    public function process(array $scriptProperties = array())
    {
        return $this->fetchTemplate('index.php');
    }

That lets me develop PHP in my view files -- nothing fancy, but full control over it.

Generating URLs in PHP

You may find it helpful to build a function that can easily create links to other CMP pages. You can add a function something like this to one of your classes that's easily available, e.g. a base controller class:

    /**
     * Gotta look up the URL of our CMP and its actions
     *
     * @param string $page default: index
     * @param array any optional arguments, e.g. array('action'=>'children','parent'=>123)
     * @return string
     */
    public static function page($page='index',$args=array()) {
        $url = MODX_MANAGER_URL;
        $url .= '?a=index&namespace=mynamespace&page='.$page;
        if ($args) {
            foreach ($args as $k=>$v) {
                $url.='&'.$k.'='.$v;
            }
        }

        return $url;
    }

Fair warning that this function has hard-coded the namespace + action + routing paramter for convenience.


Getting Controller URLs in Javascript

When you are writing a lot of AJAX calls, you may need to reference the URL of a controller in your Javascript code. Thankfully, MODX publishes a list of actions.

CMP Urls:

  // Javascript: use this URL where needed
  alert(MODx.action['namespace:controller']);

JSON Controller

The $scriptProperties array passed to the process() method includes the $_GET and $_POST arrays. You can test this by returning the contents of the array:

public function process(array $scriptProperties = array()) {
    return '<pre>'.print_r($scriptProperties, true).'</pre>';
}