Using Contexts to Customize Form and Attribute Markup

We've explained a bit about this problem already: our Express Forms look great in the Dashboard and in any theme that employs Twitter Bootstrap – but they might not look great in themes that rely on custom markup for either the controls themselves or their container html elements.

We need to make this:

Look like this:

That's our broken form, remember? Well, now that we know a bit more about how Express renders its forms, we can modify that behavior. This can be done sitewide, without worrying about custom templates, and without worrying about affecting the forms when rendered in the Dashboard.

Create a Custom Form Controller

First, we're going to need to create a custom form controller. This form controller will be used to deliver a customized form context, which will ultimately tell Concrete CMS where to load updated templates from. This custom form controller should be a PHP script found in a package somewhere, that's going to be autoloaded properly. (You can get more details about how to autoload PHP scripts in your Concrete packages from here..

In this example, my site's theme and customizations are in a package with the handle my_site. So, I've added the following code to my package controller:

protected $pkgAutoloaderRegistries = array(
    'src/MySite' => '\MySite',
);

and I'll create my custom controller here:

packages/my_site/src/MySite/Express/Controller/FormController.php

This file will look like this when it's empty:

<?php
namespace MySite\Express\Controller;
use Concrete\Core\Express\Controller\StandardController;

class FormController extends StandardController
{

}

Looks pretty simple, right? We haven't added any functionality to it yet – it simply extends the standard controller that is the default for every Concrete express form. Now, in our site's package's on_start() method, we'll tell Concrete that this controller should be used for every Express Form:

public function on_start()
{
    $this->app->make('Concrete\Core\Express\Controller\Manager')
    ->setStandardController('MySite\Express\Controller\FormController');
}

Implementing a Custom Controller For One Form

In the example above, all express form controllers will use the custom controller I'm specifying. If you'd like to enable this functionality for a single Express entity, you can do so by using the extend() method and passing the handle of the Express object in question.

public function on_start()
{
    $this->app->make('Concrete\Core\Express\Controller\Manager')
    ->extend('document', function() {
        return new MySite\Express\Controller\DocumentFormController();
    });
}

Implement a Context Registry

Now that we have a custom form controller? What can we do with it? All kinds of things. We could

  • Implement custom validation logic
  • Implement a custom notifier for when the form is submitted
  • Create a custom response handler, useful for handling redirects on submission
  • Create a custom context registry, which delivers custom form contexts in certain situations

It's this last one that we're currently interested in: with a custom context registry, we can deliver custom context objects when the form is rendered in certain situations. These context objects allow us to specify where templates should be loaded from. Let's create this custom context registry:

Create the class

Create a file named FrontendFormContext.php at packages/my_site/src/MySite/Express/Form/Context/FrontendFormContext.php. Let's make this file blank for now, but make it extend the front-end form context in the core:

<?php
namespace MySite\Express\Form\Context;

use Concrete\Core\Express\Form\Context\FrontendFormContext as CoreFrontendFormContext;

class FrontendFormContext extends CoreFrontendFormContext
{

}

Now, add a getContextRegistry method and a custom context registry object to your custom controller.

<?php

namespace MySite\Express\Controller;
use Concrete\Core\Express\Controller\StandardController;
use Concrete\Core\Express\Form\Context\FrontendFormContext as CoreFrontendFormContext;
use MySite\Express\Form\Context\FrontendFormContext;
use Concrete\Core\Form\Context\Registry\ContextRegistry;

class FormController extends StandardController
{

    public function getContextRegistry()
    {
        return new ContextRegistry([
            CoreFrontendFormContext::class => new FrontendFormContext()
        ]);
    }
}

What's going on here? First, we add new use statements to import our classes. Since some of them have the same name we have to use some class aliasing. Next, we implement a getContextRegistry method, which returns a Concrete\Core\Form\Context\Registry\ContextRegistry object. This object is pretty simple: it takes an array of strings matching core contexts, and values that point to the derived classes that you want to deliver. So any time a form using this controller renders a context, it will check to see what context is requested, and – if a custom context has been registered for this context, that custom context will be delivered instead!

Of course, this doesn't do us any good right now, since our custom context doesn't have any methods in it. Let's add some functionality to our custom context.

Customize the Custom Context

Open packages/my_site/src/MySite/Express/Form/Context/FrontendFormContext.php and add the following code to it:

use Concrete\Core\Filesystem\TemplateLocator;

public function setLocation(TemplateLocator $locator)
{
    $locator = parent::setLocation($locator);
    $locator->prependLocation([DIRNAME_ELEMENTS .
        DIRECTORY_SEPARATOR .
        DIRNAME_EXPRESS .
        DIRECTORY_SEPARATOR .
        DIRNAME_EXPRESS_FORM_CONTROLS .
        DIRECTORY_SEPARATOR .
        DIRNAME_EXPRESS_FORM_CONTROLS // not a typo
    , 'my_site']);
    return $locator;
}

What's this doing? Simply put, it's prepending a new location to look for templates. It's actually the same exact path the core uses – but notice that "my_site" that's down there? That means we'll look for this template within the my_site package before we look in the core, for any and all templates that employ this custom context.

Copy The Custom Control Templates

Next, copy all of the relevant express custom control templates from concrete/elements/express/form/form into packages/my_site/elements/express/form/form. In my site, I have

packages/my_site/elements/express/form/form/form.php
packages/my_site/elements/express/form/form/association/select.php

Then make your customizations. These may include different tags than fieldset, different headers, adding additional markup to the form, or anything, really.

Customize the Attributes

While this will work well for any of the custom express control types like text, association or the form, you'll need to do a little more to work to actually start customizing the attributes for the form. This is the purpose of the getAttributeContext method found in our custom FrontendFormContext Express form context class. It lets us do for attributes what we have already done for the express form controls.

So let's create a custom attribute form context class.

Create the class

Create a file named FrontendFormContext.php at packages/my_site/src/MySite/Attribute/Context/FrontendFormContext.php. Let's make this file blank for now, but make it extend the front-end attribute form context in the core:

<?php
namespace MySite\Attribute\Context;

use Concrete\Core\Attribute\Context\FrontendFormContext as BaseFrontendFormContext;

class FrontendFormContext extends BaseFrontendFormContext
{

}

Looks familiar, right? Now, let's make our Express Form Context class use this attribute context class for when it renders attributes, by adding getAttributeContext to our previous class. Open packages/my_site/src/MySite/Express/Form/Context/FrontendFormContext.php and add the following code to it:

public function getAttributeContext()
{
    return new \MySite\Attribute\Context\FrontendFormContext();
}

Now, any time this Express Form Context needs to render an attribute, it will do so using this context. Now we can modify that context to force attributes to include their templates from an alternate location.

Customize the Attribute Context

Open packages/my_site/src/MySite/Attibute/Context/FrontendFormContext.php and add the following code to it:

public function __construct()
{
    parent::__construct();
    $this->preferTemplateIfAvailable('site', 'my_site');
}

public function setLocation(TemplateLocator $locator)
{
    $locator->setTemplate(['site', 'my_site']);
    return $locator;
}

So what are we doing here? A couple things. First, the attribute context's preferTemplateIfAvailable is specific to attribute contexts. It means that if a particular attribute is being rendered in this context, we will attempt to load this template before loading anything else.

For example, let's say we're rendering a text attribute in a form. Typically, that means the attribute would render concrete/attributes/text/form.php (along with running form() method.) It's been this way in Concrete for a very long time. This method means that whenever an attribute is rendered in this particular context, we will first look to packages/my_site/attributes/text/site.php, and then fall back to looking for form.php in the core if that file doesn't exist. This only affects the actual control, since that's all that is typically included in the form.php found in an attribute's folder.

Next, let's look at the setLocation() method. This is responsible for setting up the HTML surrounding an attribute. In our example, we're setting the template used to 'site.php' found in the my_site package. We're not modifying the location for these templates, which means the path's for them will stay the same. That means with that simple one line of code, we're now looking for all wrapper templates in a completely new location – and only including them if they exist.

Copy and Modify the Outer Template Files

Now we can change the outer templates from bootstrap into markup that my theme supports. Copy concrete/elements/form/bootstrap3.php and concrete/elements/form/grouped/bootstrap3.php into packages/my_site/elements/form/site.php and packages/my_site/elements/form/grouped/site.php and make changes as necessary.

Create the Attribute Form Templates

Now that we've modified the outer HTML for each attribute control, let's modify any attribute forms for this context. In my site, I've modified the following attributes

  • address
  • email
  • number
  • select
  • text
  • textarea
  • url

By creating directories in packages/my_site/attributes/ (for each attribute) and then creating a file within the directory named site.php. Why does this work again? It all comes down to this code:

public function __construct()
{
    parent::__construct();
    $this->preferTemplateIfAvailable('site', 'my_site');
}

Basically, any time an attribute in this context is rendered, site.php for the attribute in question will be loaded from packages/my_site/attributes/text/site.php if it exists (where text is the attribute handle in question.)

Once you copy the form.php files from these attributes into the new location you can make any changes you'd like to conform to your theme, confident that this file will only be used in the front-end form context. You're not forking the attribute for all uses – composer, the attributes panel, and the Dashboard will all remain unchanged. Here's what my packages/my_site/attributes/text/site.php file looks like:

<?php
defined('C5_EXECUTE') or die("Access Denied.");
?>
<div class="field">
    <?php
    print $form->text(
        $this->field('value'),
        $value,
        [
            'placeholder' => $this->akTextPlaceholder
        ]
    );

    ?>
</div>

Pretty simple and mostly unchanged from the stock attribute form – but notice I've added <div class="field"></div> around my control - because my form needs it.

Conclusion

That's a lot to take in, but hopefully this document and the preceding one gives you some idea about the power and flexibility of Express Forms and their attributes. With this system you can easily modify attributes and form templates for specific areas of your site – or even for specific forms (since this is all driven by the form controller architecture.)

Want more information about what's possible from within an Express Form Controller? Read on for advanced examples.