Skip to main content

This site requires you to update your browser. Your browsing experience maybe affected by not having the most up to date version.

UncleCheese
19th March 2015

In this lesson, we'll introduce the concept of a controller action, which is a URL route that executes a function on a controller.

Controller Actions / DataObjects as Pages

Level: Beginner

Duration: 14:54

In this lesson:

What we'll cover

  • What are controller actions, and how are they used?
  • Create a controller action to render a DataObject
  • Rendering a DataObject as a page
  • Adding pseudo-page behaviour to a DataObject

How controller actions work

Up to this point in our project, for the most part, every page has been on a single URL, which that URL points to a single controller, which renders a single $Layout template. However, if you think back to our lesson on forms, you may remember that we were able to extend the URL route for our controller in order to generate and render a form. We did this using a controller action. Forms are just one of many use cases for a controller action.

Using controller actions is simple. All we're talking about is appending a URL part to an existing URL that matches the name of a publicly accessible method on the controller. Let's give it a try.

For this example, we're going to look at our Regions page. You'll see that it resolves to http://[your base url]/regions. Try appending a new segment to the URL, like http://[your base url]/regions/test. Not surprisingly, we get a 404.

Breaking down the request

The reason why we get a 404 might surprise you. Let's take a look behind the scenes and see how SilverStripe is resolving this. Using the same URL, append ?debug_request.

Let's take a look at what's going on here.

Testing '$Action//$ID/$OtherID' with 'test' on RegionsPage_Controller

Right out of the gate, we can see that SilverStripe resolved our URL to RegionsPage_Controller, which may come as a surprise, since the URL for this page does not include /test/, but what has happened is that the request handler has found a match for the URL and will assume that from this point forward, everything in the URL is a parameter being passed to the controller. By default, a controller gives you three extra parameters to pass beyond its base url, as we can see in our debug output.

  • $Action: Immediately follows the URL. In this case our action is test.
  • $ID: An ID that the controller action may want to use. This value does not have to be numeric. It's arbitrary, and just named ID because that's a common use case.
  • $OtherID: Same as ID. You get two.

You're not limited to this signature of parameters. In future lessons, we'll look at creating custom URL rules, but by default, this is what you get, and it's often all you need.

Let's look at the next line of debug output.

Rule '$Action//$ID/$OtherID' matched to action 'handleAction' on RegionsPage_Controller. Latest request params: array ( 'Action' => 'test', 'ID' => NULL, 'OtherID' => NULL, )

So here we are. The request handler actually did match the $Action/$ID/$OtherID pattern, and it's trying to resolve our action, test. In the rest of the output, you can see that it fails to do that, and it renders an ErrorPage.

Why did it fail? As we said before, the $Action parameter should represent a publicly accessible function on the controller. We have no method called test right now.

Let's add that controller method now.

mysite/code/RegionsPage.php

class RegionsPage_Controller extends Page_Controller {

  public function test() {
        die('it works');
    }

}

Allowed actions

Now try accessing the URL /regions/test. It still 404s. What's going on here?

If you recall from our Lesson 11 tutorial on forms, we're not quite done yet. We have to whitelist the test method as one that can be invoked through the URL. You can imagine the security risk that would be imposed by allowing all public methods on a controller to be invoked arbitrarily in the URL. We don't want that. By default, no methods are allowed to be called through controller actions. You need to specify a list of those that are in a private static variable called $allowed_actions.

class RegionsPage_Controller extends Page_Controller {

    private static $allowed_actions = array (
        'test'
    );

    public function test() {
        die('it works');
    }

}

$allowed_actions can actually get quite complex. You can map these methods to required permission levels, and even custom functions that evaluate whether they should be accessible at runtime, which is really useful for complex controllers. In this case, we just want to make sure anyone can invoke the test action.

Refresh the page with a ?flush, as we changed a private static variable. Now it works.

Creating a controller action to render a DataObject

The most common use for a controller action is to assign a URL to a DataObject that is nested in a Page, and this is, in my opinion, one of the first milestones of becoming a skilled SilverStripe developer.

We know that DataObjects are more primitive than Page objects. They contain none of the functionality for rendering a template, they have no Link() method, no meta tags, no controllers, etc. In short, they're not meant to be rendered as full pages. That doesn't mean, however, that you cannot assign them some of the properties necessary to do so. In fact, it often makes a lot of sense to.

Let's keep the focus on our RegionsPage. We have a list of Region DataObjects that are related to the page via has_many. We want to create a detail view for each one of the regions in our list. The user should be able to click on one of the regions and see more information.

This isn't an ideal use case for a DataObject as a page. These Region objects could just as well be pages in the site tree. It often comes down to a judgment call for the developer, guided by what will work best for the content editor. In a future tutorial, we'll look at creating a detail page for our Property DataObject, which, due to their volume, will be much more effective than managing them as pages.

Adding a Link() method

We can start with the most fundamental requirement. Regions should be able to produce a distinct link to their detail page. Let's add a Link() method to each Region. We'll have it invoke a controller action that we have not yet defined.

class Region extends DataObject {
        //...
    public function Link() {
        return $this->RegionsPage()->Link('show/'.$this->ID);
    }
}

We get the RegionsPage that owns this Region via the has_many / has_one parity, and call its link method. We pass in some extra URL segments we want appended to its link. We specify an $Action of show and an $ID that represents the Region's ID.

Now that we have that method, we'll apply it to the template. Change all the hash (#) links to $Link.

themes/one-ring/templates/Layout/RegionsPage.ss

<% loop $Regions %>
<div class="item col-md-12"><!-- Set width to 4 columns for grid view mode only -->
    <div class="image image-large">
        <a href="$Link">
            <span class="btn btn-default"><i class="fa fa-file-o"></i> Read More</span>
        </a>
        $Photo.CroppedImage(720,255)
    </div>
    <div class="info-blog">
        <h3>
            <a href="$Link">$Title</a>
        </h3>
        <p>$Description</p>
    </div>
</div>
<% end_loop %>

Give it a try. Click on one of the regions. The expected result should be a 404.

Getting the DataObject in the action

Fortunately, we know how to fix this 404 now. Let's create a show method in RegionsPage_Controller and whitelist it in $allowed_actions.

class RegionsPage_Controller extends Page_Controller {

    private static $allowed_actions = array (
        'show'
    );

    public function show(SS_HTTPRequest $request) {

    }

}

We're not doing anything new here, other than ensuring that the show method gets its SS_HTTPRequest argument. We'll need that object for getting the ID.

Now that we have the skeleton of how this is going to work, we'll build out the show method to fetch and return the Region being requested.

class RegionsPage_Controller extends Page_Controller {
        //...
    public function show(SS_HTTPRequest $request) {
        $region = Region::get()->byID($request->param('ID'));

        if(!$region) {
            return $this->httpError(404,'That region could not be found');
        }

        return array (
            'Region' => $region
        );
    }

}

This should be pretty intuitive, but let's walk through it.

  • We get the region by the ID contained in the ID request parameter. If that parameter is null, we don't have to worry. The byID() method fails gracefully.
  • If a region doesn't exist, return a 404.
  • Return a new variable, $Region to the template. (we'll deal with this next)

Rendering a DataObject as a page

As we saw in the debug output of the request handler, the $Action parameter maps to a method called handleAction on our controller. While our action method itself is designed to do everything it needs to do, the handleAction() method that invokes this method is somewhat opinionated. Specifically, it will automatically select a template for us, unless we declare otherwise.

A controller action will try to render a template following the naming convention [PageType]_[actionName].ss. In our case, that gives us RegionsPage_show.ss. Let's create that template.

Copy your themes/one-ring/templates/Layout/Page.ss to RegionsPage_show.ss in the same location. Remove the <div class="main ..."> block, and in its place, render some content from the $Region object we passed. This is a great opportunity to use the <% with %> block.

themes/one-ring/templates/Layout/RegionsPage_show.ss (line 5)

<div class="main col-sm-8">
    <% with $Region %>
        <div class="blog-main-image">
            $Photo.SetWidth(750)
        </div>
        $Description
    <% end_with %>
</div>

Try clicking on a region now and see that you get its detail page.

One thing that's a bit odd right now is that the $Description field is presented exactly the same way on the list view as it is on the detail view, which makes this click effectively pointless. Let's update the Region DataObject to store its Description field as an HTMLText field so that it could conceivably be much longer.

mysite/code/Region.php

class Region extends DataObject {

    private static $db = array (
        'Title' => 'Varchar',
        'Description' => 'HTMLText',
    );

Run dev/build.

We'll also need to update the CMS field for Description.

class Region extends DataObject {
        //...
    public function getCMSFields() {
        $fields = FieldList::create(
            TextField::create('Title'),
            HtmlEditorField::create('Description'),
            $uploader = UploadField::create('Photo')
        );
        //...

Now on RegionsPage.ss, let's just use $Description.FirstParagraph.

Adding pseudo-page behaviour to a DataObject

There are still a few things missing from making this DataObject really feel like a page. The most glaring problem is that the title of the page is still Regions, rather than the more appropriate title of the Region we're looking at. Normally, we'd find the $Title variable in our template and simply change it to $Region.Title, but that variable doesn't live in the RegionsPage_show.ss template, so we'll need to override it in the controller.

Overloading model properties in the controller

Remember the array we passed to the template containing our custom variable $Region? We can use that to overload properties that the template would normally infer from the model. Let's add Title to that list.

mysite/code/RegionsPage.php

class RegionsPage_Controller extends Page_Controller {
        //...
    public function show(SS_HTTPRequest $request) {
                //...
        return array (
            'Region' => $region,
            'Title' => $region->Title
        );
    }

}

Refresh the page and see that we get a new title.

While the new title is showing on the page itself, it is not affecting the <title> tag. That's because, back in Lesson 3, we handed over control over the title tag to $MetaTags. Back in that lesson, we discussed the option to pass a parameter of false to the $MetaTags function to suppress the title tag, and customise it as we see fit. Let's do that now.

themes/one-ring/templates/Page.ss (line 8)

    $MetaTags(false)
    <title>One Ring Rentals: $Title</title>

Refresh the page, and see that the title tag is now working.

Creating peer navigation

Another enhancement we can make is the perception of hierarchy. We can use our subnavigation section to display all the peer regions, with some state applied to the current one.

themes/one-ring/templates/Layout/RegionsPage_show.ss (line 15)

<div class="sidebar gray col-sm-4">
    <h2 class="section-title">Regions</h2>
    <ul class="categories subnav">
        <% loop $Regions %>
            <li class="$LinkingMode"><a class="$LinkingMode" href="$Link">$Title</a></li>
        <% end_loop %>
    </ul>
</div>

Remember that, even though the content is all driven by the Region object, we're still in the scope of RegionsPage_Controller, so we step into <% loop $Regions %> to get that has_many relation that we also use on list view.

Refresh the page and see that the regions are now all displaying.

Adding navigational state

We're still missing the "current" state for the region. That's because the method $LinkingMode doesn't exist on a DataObject by default, so we need to write our own.

mysite/code/Region.php

class Region extends DataObject {

        //...
    public function LinkingMode() {
        return Controller::curr()->getRequest()->param('ID') == $this->ID ? 'current' : 'link';
    }
}

Remember that we're in the context of a simple DataObject here, so we don't have any awareness of the request. Controller::curr() is a useful method that gets us the currently active controller. Off that object, we can get the request object that has been assigned to it, and look for ->param('ID'), the same way we did in our show() action. If the ID in the URL matches this region, we return current, otherwise we return link. We don't have to worry about section for something this simple.

Refresh the page and see that the current region is now indicated.

Keep learning!

Building a Search Form

In this lesson, we'll create a form that can filter our listings by multiple parameters.

Lists and Pagination

In this lesson, we'll do an overview of different types of lists in SilverStripe, and we'll use PaginatedList to add pagination to our search results.

Ajax Behaviour and ViewableData

In this tutorial, we'll add some Ajax behaviour to our site and cover a key player in the SilverStripe Framework known as ViewableData.