Easy MODX Custom Manager Pages (CMPs) V

Using JavaScript with a real processor


In the last article, we had our search working, but with a page reload. In this final article, we'll move much of our controller code into a processor (where it belongs), and use JavaScript to call that processor (via a connector) when the form is submitted.

MODX logo

See the previous articles for the Tpl chunks, menu, and namespace information.


Step One

The first step in making the form JS ready is to add an onClick() event to the submit button. Change the input for the submit button in your MySearchTpl chunk to look like this:

<input style="padding:5px;" type="submit" id="mysearch_submit"
       name="mysearch_submit" onClick="handleFormSubmit(this.form);
       return false;" value="Launch Search"/>

That will launch the JavaScript handleFormSubmit function rather than submitting the form. We'll see the JavaScript code in a bit. First, we'll look at the new, minimized controller file (home.class.php).


New Controller

This is basically our old controller minus the parts that go in the processor. Here's the code:


class mysearchHomeManagerController extends modParsedManagerController {

    protected $maxResults = 5;
    protected $results = '';
    /* Tpl for each line of results (see initialize()) */
    protected $resultTpl = '';

    public function getPageTitle() {
        return 'My Search';
    }

    /* Called first automatically by MODX */
    public function initialize() {
        parent::initialize();

        /* Change $maxResults if there's a Setting ?*/
        $maxResults = $this->modx->getOption('ms_max_results',
            null, false, true);
        if ($maxResults) {
            $this->maxResults = $maxResults;
        }

        /* Set result Tpl */
        $this->resultTpl = $this->modx->getChunk('MySearchResultTpl');

        /* Load JavaScript */
        $assetsUrl = $this->modx->getOption('mysearch.assets_url',
            null, $this->modx->getOption('assets_url') .
            'components/mysearch/', true);
        $fields = array('connector_url'  => $assetsUrl . 'connector.php');
        $this->modx->regClientStartupScript($this->modx->getChunk(
            'MySearchJS', $fields), true);
    }

    /* Main control function called automatically after
     * initialize(). */
    public function process(array $scriptProperties = []) {
        $this->modx::sanitize($_POST);
        $output = '[[!$MySearchTpl]]';
        return $output;
    }
}

As you can see, we're down to three methods, initialize, getPageTitle, and process(). Notice that we've uncommented the code in the initialize() method that loads our JavaScript (which we haven't created yet).


Creating the Connector

Ajax calls to processors in MODX are almost always done via a connector for better security. Our connector goes in the assets/components/mysearch/ directory and is called connector.php. Here's the code:


/**
 * MySearch Connector
 *
 * @package MySearch
 */

@include_once dirname(dirname(dirname(dirname(__FILE__)))) .
    '/config.core.php';

if (!defined('MODX_CORE_PATH')) {
    require_once dirname(dirname(dirname(dirname(dirname
        (dirname(dirname(__FILE__))))))) . '/config.core.php';
}

require_once MODX_CORE_PATH . 'config/' .
    MODX_CONFIG_KEY . '.inc.php';
require_once MODX_CONNECTORS_PATH . 'index.php';

// $modx->lexicon->load('mysearch:default');

/* Make sure user has permission to run the processor */
if (($modx->user->hasSessionContext('mgr') ||
        $modx->user->hasSessionContext($modx->context->key))) {
    if ($modx->user->hasSessionContext('mgr')) {
        $_SESSION["modx.{$modx->context->key}.user.token"] =
            $_SESSION["modx.mgr.user.token"];
    }
    $_SERVER['HTTP_MODAUTH'] =
        $_SESSION["modx.{$modx->context->key}.user.token"];
    $_REQUEST['HTTP_MODAUTH'] = $_SERVER['HTTP_MODAUTH'];
}

/* handle request */
$mySearchCorePath = $modx->getOption('mysearch.core_path',
    null, $modx->getOption('core_path') . 'components/mysearch/');
$path = $mySearchCorePath . 'processors/';

$modx->initialize('mgr');

$modx->request->handleRequest(array(
'processors_path' => $path,
'location' => '',
'action'=> 'mysearch',
));

The first include is for my development environment, so you can leave it out if your connector file is in the traditional location. If not, you can adjust the include to find any of the three MODX config.core.php file — usually, the one in the MODX root directory. For each level up, you add another dirname( at the front and another ) before the dot.


Adding the JavaScript

The usual method for using JavaScript is to put it in a file and inject some separate JS code in to the page that holds the config data the JS needs to do it's work. If you have a fair amount of data to pass to the JS code, that works well, but it usually involves instantiating a class file just to get the config data. For simple cases, I prefer to just put the JS code in a chunk with placeholders for the data you need to pass.

In our case, the only thing we need to pass is the URL for our Ajax call.

Here's the content of the MySearchJS chunk:

<script type="text/javascript">
"use strict";
        /* JavaScript adapted from JASON LENGSTORF
           https://code.lengstorf.com/get-form-values-as-json/ */

function urlencode(str) {
    str = (str + '').toString();
    return encodeURIComponent(str).replace(/!/g, '%21').replace(/'/g, '%27').replace(/\(/g, '%28').replace(/\)/g, '%29').replace(/\*/g, '%2A').replace(/%20/g, '+');
}

/**
 * Checks that an element has a non-empty `name` and `value` property.
 * @param  {Element} element  the element to check
 * @return {bool} true if the element is an input, false if not
 */

function isValidElement(element){
    return element.name && element.value;
}

/**
 * Checks if an element’s value can be saved
 * (e.g. not an unselected checkbox).
 * @param  {Element} element  the element to check
 * @return {Boolean} true if the value should be added, false if not
 */

function isValidValue(element) {
    return (!['checkbox', 'radio'].includes(element.type) ||
        element.checked);
}

/**
 * Checks if an input is a checkbox, because checkboxes
 * allow multiple values.
 * @param  {Element} element  the element to check
 * @return {Boolean} true if the element is a checkbox, false if not
 */

/*function isCheckbox(element) {
    return element.type === 'checkbox';
}*/
var isCheckbox = function isCheckbox(element) {
    return element.type === "checkbox";
};

/**
 * Checks if an input is a `select` with
 * the `multiple` attribute.
 * @param  {Element} element  the element to check
 * @return {Boolean} true if the element is a
 * multiselect, false if not
 */

function isMultiSelect(element) {
    return element.options && element.multiple;
}

/**
 * Retrieves the selected options from a
 * multi-select as an array.
 * @param  {HTMLOptionsCollection} options  the options for the select
 * @return {Array} an array of selected option values
 */

var getSelectValues = function getSelectValues(options) {
    return [].reduce.call(
        options,
        function (values, option) {
            return option.selected
                ? values.concat(option.value)
                : values;
        },
        []
    );
};

/**
 * Retrieves input data from a form and returns it as a JSON object.
 * @param  {HTMLFormControlsCollection} elements  the form elements
 * @return {Object} form data as an object literal
 */


var formToJSON = function formToJSON(elements) {
    var data = {};
    var objects = 'search_objects';
    data[objects]= {};
    var element;
    for (var i = 0; i < elements.length; i++) {
        element = elements[i];
        // Make sure the element has a value set.
        if (isValidElement(element) &&
            isValidValue(element)) {

           //  See if it's a field that allows for more than one value.
           //  If so, store the values as an array.

            if (isCheckbox(element)) {
                /* Get rid of [] at end of name */
                var name = element.name.replace('[]', "");

                /* Extract the text inside the remaining square brackets */
                if (name.indexOf('[') > -1) {
                    var matches = name.match(/^search_objects\[([^\]]+)]/);
                    if (matches === null) {
                       console.log('NAME: ' + name);
                    } else {
                       name = matches[1];
                    }
                }

                if (!(name in data[objects])) {
                    data[objects][name] = [];
                }
                // console.log(name + ' - ' + element.value);
                /* Add element value to [data][objects][name] */
                data[objects][name] = (data[objects][name] ||
                    []).concat(element.value);
            } else if (isMultiSelect(element)) {
                /* This has not been tested */
                data[element.name] = getSelectValues(element);
            } else {
                /* Not a checkbox or multiSelect - just store value */
                data[element.name] = element.value;
            }
        }
    }
    return data;
};

/**
 * A handler function to prevent default submission
 * and run our custom script.
 * @param  {Event} event  the submit event triggered by the user
 * @return {void}
 */
function handleFormSubmit(form) {
  /* Stop the form from submitting . */
  if (window.event) {
    window.event.returnValue = false;
  }

  // Call our function to get the form data.
  var data = formToJSON(form.elements);

  /* Set up Ajax request */
  var request = new XMLHttpRequest();

  /* This is where the results will go when they arrive */
  var dataContainer = document.getElementsByClassName("mysearch_results")[0];

  /* Act when there is a response to the Ajax call */
  request.onreadystatechange = function() {
    if (request.readyState === 4) {
      if (request.status === 200) {
          /* Display Results */
          dataContainer.innerHTML = request.responseText;
      } else {
          dataContainer.textContent =
              "An error occurred during your request: " +
              request.status +
              " " +
              request.statusText;
      }
    }
  };
  var dataToSend = [];

  dataToSend = JSON.stringify(data, null, "");

  /* connector_url placeholder set in home.class.php controller */
  request.open(
    "POST",
    "[[+connector_url]]"
  );
  request.setRequestHeader("Content-type",
      "application/x-www-form-urlencoded");
  request.send("data=" + urlencode(dataToSend));
}

/*
 *  Attach the `handleFormSubmit()` function to the
 * `submit` event.
 */

// Commented out because we're using onClick()

/*var form = document.getElementsByClassName('mysearch_form')[0];
form.addEventListener('submit', handleFormSubmit);*/
</script>

I was determined to make this JavaScript work without JQuery (or extJS/modExt). I was able to do it, but it taught me a lot about the value of JQuery when making Ajax requests. I estimate that using JQuery would have cut the programming time by at least 80%.

In many cases, MODX JavaScript returns a JSON object or array from a processor. Commonly, there's a success member set to true of false and often a count member returning the number of results. That kind of return value needs to be decoded and processed in the JS code. In our case, though, the processor itself is returning either the full output HTML or an error message. If the status (200) indicates that the processor was found, all we do is insert whatever the processor sends us into the dataContainer element.

Note that we never bothered to remove the mysearch_results placeholder in our MySearchTpl chunk. It's no longer necessary since that placeholder will never be set. Our JavaScript will be setting the entire content of that div with the code below. Nothing will be displayed there until that code executes, but the placeholder serves as a reminder of where the results will appear.

The most challenging part of the JS code is the formToJSON() function. When we were doing everything in PHP, we simply modified the form to create the $_POST array we needed. That $_POST array was sent automatically when the form was submitted. Now that we're doing everything in JavaScript without reloading the page, we need JS code that will create that same array from the form on the screen. The form itself is passed in onClick="handleFormSubmit(this.form). Our formToJSON() function just walks through the form's elements, storing the names and values in an array as it goes. That array is what we'll send to our processor via the connector. I'm curious to know if JQuery's serialize would have handled this correctly.

dataContainer.innerHTML = request.responseText;

Adding the Processor

Now that we have our JavaScript loaded and calling the processor (via the connector), we need an actual processor to handle the submission and return the results. You'll see a lot of familiar functions here that were removed from the home.class.php controller file. Here's the code:

<?php

class modMySearchProcessor extends modProcessor {
    /* Tpl for each line of results (see initialize()) */
    protected $resultTpl = '';
    protected $maxResults = 5;

    public function initialize() {
        /* Set result Tpl */
        $this->resultTpl = $this->modx->getChunk(
            'MySearchResultTpl');
        /* Change $maxResults if there's a Setting ?*/
        $this->maxResults = $this->modx->getOption(
            'ms_max_results', null, 5, true);

        /* Get data sent in the Ajax call */.
        $data = $this->getProperty('data');
        if (empty($data)) {
            return $this->modx->lexicon('invalid_data');
        }

        /* Convert data to PHP associative array */
        $data = $this->modx->fromJSON($data);
        if (empty($data)) {
            return $this->modx->lexicon('invalid_data');
        }

        $this->setProperties($data);
        $this->unsetProperty('data');

        return parent::initialize();
    }

    /** Get objects that match search criteria.
     *  Returns a collection of xPDO objects or
     *  an empty array if there are no matches */
    public function find($searchTerm, $objectType, $fieldsToSearch) {

        $criteria = array();
        $query = $this->modx->newQuery($objectType);

        /* Get and remove first field */
        $firstField = (string)array_shift($fieldsToSearch);
        if ($firstField === 'id') {
            $criteria[$firstField . ':='] = $searchTerm;
        } else {
            $criteria[$firstField . ':LIKE'] = "%" .
                $searchTerm . "%";
        }
        foreach ($fieldsToSearch as $field) {
            if ($field === 'id') {
                $criteria['OR:' . $field . ':='] = $searchTerm;
            } else {
                $criteria['OR:' . $field . ':LIKE'] = '%' .
                    $searchTerm . '%';
            }
        }

        $query->where($criteria);
        $query->limit($this->maxResults);

        $collection = $this->modx->getCollection($objectType, $query);

        return $collection;
    }

    /** Create output for one search section (object type).
     *  Returns a string with HTML for the section  */
    public function processCollection($collection, $objectType,
        $nameField, $action) {
        /** @var $collection array -- other vars are all strings */
        /* All members of $collection match the search term */
        $output = '';
        foreach ($collection as $object) {
            /** @var $object xPDOObject */
            $id = $object->get('id');
            $name = $object->get($nameField);

            /* Get the full output line for one hit */
            $output .= $this->addToResults($action, $name, $id);
        }

        /* If we have results, create the full section code for the
           current object using the MySearchSectionTpl chunk */

        if (!empty($output)) {
            /* Convert 'modResource' to 'Resources' etc. for
               the section header */
            $header = ucfirst(str_replace('mod', '', $objectType)) . 's';

            /* prepare placeholder array for getChunk() */
            $phs = array(
                'mysearch_header' => $header,
                'mysearch_id' => $objectType,
                'mysearch_rows' => $output,
            );

            $output = $this->modx->getChunk('MySearchSectionTpl', $phs);
        }
        return $output;

    }

    /* Create and return one line based on results Tpl chunk */
    public function addToResults($action, $name, $id) {
        /** @var $this ->resultTplChunk modChunk */
        $line = "\n" . str_replace('[[+action]]', $action,
            $this->resultTpl);
        $line = str_replace('[[+id]]', $id, $line);
        $line = str_replace('[[+name]]', $name, $line);
        return $line;
    }

    /* Get field containing the 'name' of the object */
    public function getNameField($objectType) {
        /* set default */
        $nameField = 'name';

        switch ($objectType) {
            case 'modResource':
                $nameField = 'pagetitle';
                break;
            case 'modUser':
                $nameField = 'username';
                break;
        }
        return $nameField;
    }
    public function getContentField($objectType) {
        /* field for resources and templates */
        $contentField = 'content';

        /* Set value for other object types */
        switch($objectType){
            case 'modChunk':
            case 'modSnippet':
                $contentField = 'snippet';
                break;

            case 'modPlugin':
                $contentField = 'plugincode';
                break;
        }
        return $contentField;
    }

    /* Get correct action for updating object */
    public function getAction($objectType) {
        $action = null;

        /* Remove 'mod' prefix anc convert to lowercase */
        $field = strtolower(str_replace('mod', '', $objectType));
        switch ($field) {
            case 'resource':
                $action = 'resource/update';
                break;
            case 'user':
                $action = 'security/user/update';
                break;
            default:
                $action = 'element/' . $field . '/update';
                break;
        }
        return $action;
    }

    public function process() {
        $output = '';
        $fields = $this->getProperties();
        /* Sanitize $_POST fields */
        $this->modx::sanitize($fields);

        $searchTerm = $this->modx->getOption('search_term',
            $fields, null, true);

        if (empty($searchTerm)){
            return '<p style="color:red">Please enter a search term</p>';
        }
        $objects = $fields['search_objects'];
        if (empty($objects)){
            return '<p style="color:red">Please select at least one field to search</p>';
        }
        foreach ($objects as $objectType => $fieldsToSearch) {
            $nameField = $this->getNameField($objectType);
            $action = $this->getAction($objectType);
            foreach($fieldsToSearch as $key => $value){
                if ($value === 'content') {
                    $fieldsToSearch[$key] = $this->getContentField($objectType);
                }
            }

            $collection = $this->find($searchTerm, $objectType, $fieldsToSearch);

            if (!empty($collection)) {
                $output .= $this->processCollection($collection, $objectType,
                    $nameField, $action);
            }
        }

        if (empty($output)) {
            $output = '<b>No Results</b>';
        }
        return $output;
    }

    public function checkPermissions() {
        return true;
    }
}

return 'modMySearchProcessor';

The code above is explained by the comments (and we've seen most of it before), but we've added one new method: getContentField(). This method allows us to use the term content in any of the sections of the form even though the "content" field of snippets and plugins is not called that. We could have renamed the name field for those inputs, but this method makes that unnecessary. The method returns the correct name of the content field for all objects except modUser, where it wouldn't make sense.


Wrapping Up

We now have a MODX CMP that uses chunks for its Tpls and a real processor to communicate with the database. With the addition of a few more lines in the MySearchTpl chunk's form, it would search for users, resources, chunks, templates, snippets, and/or plugins. It will even search in the content of any of those objects. If you do add sections, be sure to use the actual field names in your form. MySQL will complain, for example, if you try to search the pagetitle field of a chunk.

Along the way, I hope you've learned a little about the structure of MODX CMPs and how to implement them.


Improvements

One obvious improvement would be to add an input field to the form so users could select the maximum number of results to return for each object. That would be quite easy. The current JavaScript that creates the data array would insert is automatically, so it would just be a matter of extracting it in the processor's initialize() method and using it to set the limit for the query.

Another, not-so-easy, improvement would be to make the search itself more sophisticated. It could search for multiple words (a la Google) with both an and and or option. It would also be nice to be able to exclude results with a certain word or phrase and allow grouping parts of the search with parentheses, so a search term like (bread && butter) || (toast && jam) -(wheat || rye), would find objects containing both bread and butter or both toast and jam, but only if they do not contain wheat or rye. The icing on the cake would be to allow regular expression searches, which would accomplish all of the above.

At some point, I will be releasing a premium extra called CustomSearch that incorporates all of those options and does searches of resources, users, and all elements. It will even search the code of snippets and plugins, and the values of most TVs. It will include a regular expression option. Until I do, you can use the code above to develop your own search algorithms.


Coming Up

In the next article, we'll look at how APIs work.




Looking for high-quality, MODX-friendly hosting? As of May 2016, Bob's Guides is hosted at A2 hosting. (More information in the box below.)



Comments (0)


Please login to comment.

  (Login)