Extending a Core Attribute Type

Here's a fairly understandable, common use case: you're a developer creating a website for a property management company, and this company has four locations they work with:

  • Crown Plaza
  • Town Square
  • Hill Road
  • Uptown Avenue

These aren't going to change, but you're going to need to use this to tie users and perhaps pages to these locations.

Location Object

In our property management website, we've created a custom object for these locations, found at PropCo\Property\Location. (We're using custom application autoloaders to map application/src/PropCo/Property/Location.php to this custom object.). PropCo\Property\Location is the object that we used to represent these four locations. Most importantly, this is the object that we want to pass around when we're working with attributes. We don't want to work directly with the numbers that match our location IDs behind the scenes – we care about the object itself.

<?php
namespace PropCo\Property;
class Location 
{

    protected $propertyLocationID;

    const LOCATION_CROWN_PLAZA = 1;
    const LOCATION_TOWN_SQUARE = 2;
    const LOCATION_HILL_ROAD = 3;
    const LOCATION_UPTOWN_AVENUE = 4;

    public function __construct($propertyLocationID)
    {
        $this->propertyLocationID = $propertyLocationID;
    }
}

It's clear from our custom code that Crown Plaza has the numerical ID of 1, Town Square 2, etc... So if we want to use our custom objects in our attribute, let's create completely new custom attribute type called "property_location", but base it off of the number custom attribute. Why? Since we're going to be storing numbers corresponding to these locations, we don't have to write nearly as much custom code. All the settings and value objects will just work as they do today, and we can concern ourselves with creating a custom controller that will translate these stored numbers into our location objects.

Create the Controller

First, create a controller.php file at application/attributes/property_location/controller.php. Much like with custom block types, the application/attributes directory is where custom attribute types live. This file can be mostly empty at this point, except for the relevant namespace and class name, which we'll want to make a subclass of the Concrete\Attribute\Number\Controller class, since we're using the number data fields to store this attribute's value.

<?php
namespace Application\Attribute\PropertyLocation;
class Controller extends \Concrete\Attribute\Number\Controller
{

}

Add the Attribute Type

From the /dashboard/system/attributes/types page in your site, find the custom attribute types listing at the bottom of the screen and click Install. (Note: if you get an error on this screen, or don't see any attribute listed, disable overrides caching by going to /dashboard/system/optimization/cache and finding the Overrides Caching section. More information can be found here..

Don't forget to assign the attribute type one or multiple attribute categories on this same screen, otherwise you won't be able to add the attribute type to any pages, files or users.

Add an Attribute Key

Now we need to create an instance of the attribute type that we can use to tie our property location object to a page, file or user. Let's create it against pages. Create a new 'property_location' attribute key with the handle 'property_location' in Page Attributes:

First, select property location as the new attribute's type, and then create the key:

And there we have an attribute key we can use against pages. Now it's time to implement some controller methods and create some attribute templates. If we don't implement any controller methods or any custom forms, our attribute will look and function exactly like the number attribute, since that's the class from which it extends.

Icon

The easiest place to start is with the functionality that determines the icon used by our attribute. This is the getIconFormatter method. Implement this method and return an object of the Concrete\Core\Attribute\IconFormatterInterface. The most common one used by the core currently is Concrete\Core\Attribute\FontAwesomeIconFormatter, which simply takes the icon name of a Font Awesome icon as it single constructor argument.

First, we'll figure out what icon to use. Since this is a property location, let's give this attribute the icon of a house. If you check that link, you'll see that the icon of a house in Font Awesome is named "home" so that's what we'll use.

Next, import the class definition in your controller by adding this to the top of the class:

use Concrete\Core\Attribute\FontAwesomeIconFormatter;

Then, implement the method in the controller:

public function getIconFormatter()
{
    return new FontAwesomeIconFormatter('home');
}

That's it! Everywhere attributes of this type are used or listed, they will use this icon.

Form

Now that we have the icon taken care of, let's handle the portion of the attribute that displays the form when adding the attribute to a page. (Note: don't confuse this with the "Type Form", which is what is rendered when creating a new instance of the attribute type.)

When adding our Property Location custom attribute to a page, we want it to look like a select menu with our four locations. So let's implement a custom form() method in our controller, and create a custom form.php template in our application/attributes/property_location/ directory. (Note: whenever rendering the attribute form, the controller's form() method is rendered first, if it exists, and then the form.php template is included, if it exists.)

public function form()
{

}

Our form() method is empty for now. Now, let's create a form.php template. It will contain a select menu that corresponds to our use case. In application/attributes/property_location/form.php, we'll add the following code:

<select class="form-control" name="<?=$view->field('value')?>">
    <option value="">Select a Location</option>
    <option value="1">Crown Plaza</option>
    <option value="2">Town Square</option>
    <option value="3">Hill Road</option>
    <option value="4">Uptown Avenue</option>
</select>

A couple notes here:

First, you'll notice the name we're giving to our single select control: <?=$view->field('value')?>. $view is a variable corresponding to the current Concrete\Core\Attribute\View object. It is automatically injected and available within any attribute template. View::field() is a method that takes a readable parameter name and translates it into a form control name that is guaranteed to be unique on the page (since, after all, multiple attributes of the same type can be used on one page. What if you created two property location attributes and put them on the same page? You'd need to ensure that the system could differentiate between the location of attribute A and attribute B.)

Why did we choose 'value' as the argument for our field method? Because 'value' is the name of the field used by the number attribute. Since we extend that, if we name our form element the same way, we can reuse some of the form processing methods that the number attribute provides.

Next, you'll notice we're hard-coding our IDs in our select. This is for the purpose of this documentation. Ideally, you'd replace 1, 2, 3 and 4 in this example with PropCo\Property\Location::LOCATION_CROWN_PLAZA, PropCo\Property\Location::LOCATION_TOWN_SQUARE, etc...

That's it. Now when we add this attribute to the page, we see our nice select menu:

And if we save the attribute, the ID of the selected option is saved behind the scenes.

Edit Form

Now let's make it so that the property location is selected in the select menu, when we edit a page that has a location selected. Let's pass the selected value number into the form template, by adding this code into our empty form() method:

public function form()
{
    if (is_object($this->attributeValue)) {
        $value = $this->attributeValue->getValue();
        $this->set('propertyLocationID', $value);
    }
}

And modifying the code of our form template to look like this:

<select class="form-control" name="<?=$view->field('value')?>">
    <option value="">Select a Location</option>
    <option value="1" <?php if (isset($propertyLocationID) && $propertyLocationID == 1) { ?>selected<?php }  ?>>Crown Plaza</option>
    <option value="2" <?php if (isset($propertyLocationID) && $propertyLocationID == 2) { ?>selected<?php }  ?>>Town Square</option>
    <option value="3" <?php if (isset($propertyLocationID) && $propertyLocationID == 3) { ?>selected<?php }  ?>>Hill Road</option>
    <option value="4" <?php if (isset($propertyLocationID) && $propertyLocationID == 4) { ?>selected<?php }  ?>>Uptown Avenue</option>
</select>

Let's walk through this code. First, in the form() method, we check to see if the attributeValue property of the controller is defined. It's important to remember that the form() method runs when attributes are added and edited, and its only in the case of the latter than the attributeValue property will exist. This is why we perform the check. Controller::$attributeValue will always be an object of the Concrete\Core\Attribute\AttributeValueInterface, but currently the only really workable implementation of this is the Concrete\Core\Entity\Attribute\Value\AbstractValue object and the classes (like PageValue, FileValue, UserValue) that extend from it.

This interface specifies a method named getValue(), which, when run, is responsible for returning the value of the attribute in its native format? What does that mean? The native format is the response that the attribute works most directly with. This means that the Image/File attribute returns an instance of the Concrete\Core\Entity\File\File object, the Number attribute returns a decimal, the Textarea attribute returns text, and the datetime attribute returns a DateTime object. Other methods return their values in different formats, but getValue() should return the format most relevant to that attribute type. That's because the getAttribute() method found on pages, users and files (and any other custom attribute categories) retrieves this value first and foremost.

Since we're extending the Number attribute controller, the getValue() method above returns a decimal. That means we can just pass it directly into our form template. We pass it in as the variable name $propertyLocationID, in order to make our template make sense. Then, we simply check to see if $propertyLocationID is set and equals a particular location ID from within our template, and that lets us select the property option.

Modifying getValue()

We could be finished right here. At this point, I'm able to save the number value against a page, and the attribute view shows my select menu properly, using the number data value objects behind the scenes.

However, let's say I want some more programmatic niceness. For example, if I retrieve my property location from a page programmatically

$propertyLocation = $page->getAttribute('property_location'); // Returns 1, 2, 3 or 4

I'm going to be getting a number value object back in response. This is fine, as I could then call this custom code to get my location object.

$propertyLocationID = $page->getAttribute('property_location');
$location = new \PropCo\Property\Location($propertyLocationID);

However, wouldn't it make much more sense for our Property Location attribute to actually return the proper property Location object when you call $page->getAttribute()? This is an example of an attribute type not returning an object in its native value format. We should fix that. Fortunately, this can all be done within our custom attribute controller, without having to worry about changing how we use the number attribute data value objects behind the scenes.

First, import the property location object to the class:

use PropCo\Property\Location;

And add this this method to the Application\Attribute\PropertyLocation\Controller class:

public function getValue()
{
    $value = $this->attributeValue->getValueObject();
    if ($value) {
        return new Location($value->getValue());
    }
}

Let's walk through this as well. Within getValue() we get the abstract value we mentioned above, and on that value we call getValueObject, which is also part of the AttributeValueInterface. This returns the direct data value object used by our attribute – in this case, its Concrete\Core\Entity\Attribute\Value\Value\NumberValue, since we're basing our attribute off of the number attribute. Then, if that value exists, we create our location using the NumberValue::getValue() method (which returns a decimal.)

Important

This code looks a little different then code we had before. In the form() method above, we used $this->attributeValue->getValue() and here we're using $this->attributeValue->getValueObject() – why? Can't we just use getValue() to skip straight to getting the number attribute value's decimal representation? No! The reason for this is because getValue() on the attribute value object actually first checks to see if the attribute type itself has a custom getValue() method in the controller, and if it does it uses that to retrieve the value. This lets us do exactly what we're doing here. So if we were to use $this->attributeValue->getValue() and specify a getValue() method in the controller as we're doing here, we'd end up in a recursive loop. So don't do that. Instead, get the underlying data value object and use that.

Update form() Method

If you're paying attention you'll realize that this means we need to change some underlying code here as well. Our existing form() method needs to be changed to accommodate this change in code. Changing it to this should do the trick:

public function form()
{
    if (is_object($this->attributeValue)) {
        $number = $this->attributeValue->getValueObject();
        if (is_object($number)) {
            $this->set('propertyLocationID', $number->getValue());
        }
    }
}

Add getSearchIndexValue()

Now that we've updated getValue() to return an object, we have to add a special method named getSearchIndexValue() to our controller. getSearchIndexValue() returns data in the format the search indexer expects for this particular attribute type. This is documented elsewhere – but it's important to note that the number attribute controller uses getValue() to return a decimal for its search indexer. Since we've updated getValue() to return an object, unless we provide a new getSearchIndexValue() method to return the underlying number, we're going to attempt to insert an object into a search index database table, and that will lead to errors. So let's add this to our controller:

public function getSearchIndexValue()
{
    if ($this->attributeValue) {
        $value = $this->attributeValue->getValueObject();
        return $value->getValue();
    }
}

It follows the same pattern as other methods, and should be fairly straightforward.

Conclusion

That's it! We've created a custom attribute type with very little code, based off of an existing core attribute type. There are so many custom attribute types in the core that this approach should be sufficient for many different attribute types.

If you need more flexibility, including the ability to use custom data objects or custom settings objects to store data about your attributes, you'll want to read on for more information.

Loading Conversation