Creating Attribute Types and Extending Core Attribute Types

Improvements?

Let us know by posting here.

Extending a Core Attribute Type

Imagine you're making a website for a property company with four locations: Crown Plaza, Town Square, Hill Road, and Uptown Avenue. You need a way to link users and pages to these locations.

Location Object

We created a custom object for these locations at PropCo\Property\Location. This object represents the locations, and we want to use it with attributes, not just their ID numbers.

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;
    }
}

We'll make a new attribute type called "property_location" based on the number attribute. This lets us store location numbers easily.

Create the Controller

Start by making a controller.php file at application/attributes/property_location/controller.php. This is where custom attribute types are stored. The file should start with this basic setup:

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

}

Add the Attribute Type

Go to /dashboard/system/attributes/types on your site. Click Install at the bottom where custom attribute types are listed. If there's an error or nothing shows up, turn off overrides caching at /dashboard/system/optimization/cache.

Remember to assign the attribute type to categories or you can't use it.

Add an Attribute Key

Now, create a 'property_location' attribute key for pages. Choose property location as the type and set up the key.

Next, we'll work on controller methods and attribute templates. Our attribute will act like a number attribute if we don't add custom parts.

Icon

First, we choose an icon. For property location, a house icon fits. We'll use "home" from Font Awesome.

Add this at the top of your controller:

use Concrete\Core\Attribute\FontAwesomeIconFormatter;

Then, add the getIconFormatter method:

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

This sets the icon for our attribute.

Form

For the attribute form on a page, we want a menu with our four locations. We'll need a form() method in the controller and a form.php template in application/attributes/property_location/.

The form() method can be empty to start. In form.php, add this code for the select menu:

<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>

The <?=$view->field('value')?> part ensures the form works correctly, even with multiple similar attributes on one page.

Now, when you add this attribute to a page, you'll see a neat selection menu.The selected location's ID is saved automatically.

Edit Form

To select a property location in the edit page form, add this to the form() method:

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

Modify the form template:

<select class="form-control" name="<?=$view->field('value')?>">
    <option value="">Select a Location</option>
    <!-- Repeat for each location -->
    <option value="1" <?php if (isset($propertyLocationID) && $propertyLocationID == 1) { ?>selected<?php }  ?>>Crown Plaza</option>
    <!-- ... other options ... -->
</select>

This code checks if attributeValue exists in form() and passes propertyLocationID to the template. The template then selects the appropriate option based on propertyLocationID.

Modifying getValue()

Enhance the attribute to return a location object. Add this to Application\Attribute\PropertyLocation\Controller:

use PropCo\Property\Location;

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

This modification enables getValue() to return a Location object instead of just a numeric value.

Important

Avoid recursive loops by using getValueObject() in the getValue() method, not getValue(), because getValue() checks for a custom getValue() in the controller.

Update form() Method

Update the form() method to accommodate the changes:

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

Add getSearchIndexValue()

To maintain compatibility with the search indexer, implement getSearchIndexValue():

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

This ensures the search indexer receives data in the expected format.

Customizing Attribute Types with Value and Settings Objects

To enhance our attribute, we're adding custom settings and data. This enables creating versatile attribute types.

The "Property Location" attribute lets us select a location for a page. We'll expand it to:

  1. Allow choosing between a select menu or radio buttons when adding the attribute.
  2. Enable a custom text label for the location when saving the attribute. This label will be displayed when presenting the attribute.

This requires several updates:

  1. The attribute controller should no longer inherit from Concrete\Attribute\Number\Controller. It needs its own data value objects.
  2. Add a custom label to PropCo\Property\Location for usage in our custom object.
  3. Introduce a new settings entity to determine if the attribute key displays as a select list or radio buttons.
  4. Create a new data value object storing the selected location ID and possibly a custom label.

Concrete CMS's attribute system simplifies adding these elements.

Update the Controller

Change the controller's inheritance for default base attribute controller:

use Concrete\Core\Attribute\Controller as AttributeController; 
class Controller extends AttributeController

Add search indexing for an integer in the controller:

protected $searchIndexFieldDefinition = ['type' => 'integer', 'options' => ['default' => 0, 'notnull' => false]];

Update PropCo\Property\Location

Add a custom label, getter, and setter in the constructor:

namespace PropCo\Property;
class Location {
    protected $propertyLocationID;
    protected $customLabel;

    // Constants for location IDs here

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

    // Getter methods for ID and label here
}

Create a Settings Object and Form

Create a type_form.php template and type_form() method in the controller for adding/updating attributes:

public function type_form() {
    // Form content here
}

In type_form.php, include a fieldset for form settings:

<fieldset>
    <legend><?=t('Location Settings')?></legend>
    <div class="form-group">
        <label class="control-label" for="formDisplayMethod">Form Display Method</label>
        <select class="form-control" name="formDisplayMethod">
            <option value="select">Select Menu</option>
            <option value="radio_list">Radio Button List</option>
        </select>
    </div>
</fieldset>

Create the Settings Entity

Create PropertyLocationSettings class with Doctrine ORM annotations for database setup. Place this file in application/src/Entity/Attribute/Key/Settings/PropertyLocationSettings.

namespace Application\Entity\Attribute\Key\Settings;

use Concrete\Core\Entity\Attribute\Key\Settings\Settings;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="atPropertyLocationSettings")
 */
class PropertyLocationSettings extends Settings {
    /**
     * @ORM\Column(type="string")
     */
    protected $formDisplayMethod = false;

    // Getter and setter for formDisplayMethod here
}

After adding the file, refresh entities in Dashboard > System and Settings > Environment > Database Entities.

Save the Data

Import PropertyLocationSettings into the controller and implement methods to manage settings:

use Application\Entity\Attribute\Key\Settings\PropertyLocationSettings;

public function getAttributeKeySettingsClass() {
    return PropertyLocationSettings::class;
}

public function saveKey($data) {
    // Code to handle saving settings based on POST data
}

With these changes, our attribute system now handles more complex interactions and displays.

Update Type Form with Settings

Update type_form():

public function type_form()
{
    $settings = $this->getAttributeKeySettings();
    /**
     * @var $settings PropertyLocationSettings
     */
    $this->set('formDisplayMethod', $settings->getFormDisplayMethod());
}

Update type_form.php:

<fieldset>
    <legend><?= t('Location Settings') ?></legend>
    <!-- ... existing form elements ... -->
</fieldset>

Update Form Rendering Based on Settings

Modify form():

public function form()
{
    // ... existing code ...
    $settings = $this->getAttributeKeySettings();
    /**
     * @var $settings PropertyLocationSettings
     */
    $this->set('formDisplayMethod', $settings->getFormDisplayMethod());
}

Change form.php for radio list settings:

<?php if (isset($formDisplayMethod) && $formDisplayMethod == 'radio_list') { ?>
    <!-- Radio button list -->
<?php } else { ?>
    <!-- Select menu -->
<?php } ?>

Create Data Value Entity

Create PropertyLocationValue entity:

<?php
namespace Application\Entity\Attribute\Value\Value;

use Concrete\Core\Entity\Attribute\Value\Value\AbstractValue;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="atPropertyLocation")
 */
class PropertyLocationValue extends AbstractValue
{
    // ... properties and methods ...
}

Refresh entities in Dashboard.

Update Controller for New Data

Modify form() method:

public function form()
{
    // ... updated code to use PropertyLocationValue ...
}

Add Custom Label to Form

Update form template:

<?php if (isset($formDisplayMethod) && $formDisplayMethod == 'radio_list') { ?>
    <!-- Radio buttons -->
<?php } else { ?>
    <!-- Select menu -->
<?php } ?>

<!-- Custom label input -->

Update Search Indexer

Modify getSearchIndexValue():

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

Handle Data Saving

Implement required methods:

  • createAttributeValueFromRequest()
  • createAttributeValue($mixed)
  • getAttributeValueClass()

Implementations should manage the attribute data and return PropertyLocationValue instances.

Including Attribute Type in Package

Check the packaging a theme documentation to understand Concrete CMS package format and the package controller file.

To add a custom attribute type, like 'Property Location', to a package, such as 'Property Manager' with handle property_manager:

  1. Create package directories:

    mkdir packages/property_manager
    mkdir packages/property_manager/attributes/
    
  2. Move entities from application/src/Entity to packages/property_manager/src/Concrete/Entity and update their namespaces from PropCo\Property\Location to Concrete\Package\PropertyManager\Entity.

  3. After creating your package controller, move your property_location attribute type:

    mv application/blocks/property_location packages/property_manager/attributes/property_location
    
  4. In your package's install() method, use the Concrete\Core\Application\Application to instantiate Concrete\Core\Attribute\TypeFactory:

    public function install()
    {
       $pkg = parent::install();
       $factory = $this->app->make('Concrete\Core\Attribute\TypeFactory');
       $type = $factory->getByHandle('property_location');
       if (!is_object($type)) {
           $type = $factory->add('property_location', 'Property Location', $pkg);
       }       
    }
    
  5. To associate this attribute type with pages:

    $service = $this->app->make('Concrete\Core\Attribute\Category\CategoryService');
    $category = $service->getByHandle('collection')->getController();
    $category->associateAttributeKeyType($type);
    

$service->getByHandle('collection') returns a Concrete\Core\Entity\Attribute\Category object. Use getController on this entity to obtain the Concrete\Core\Attribute\Category\PageCategory class.

Making Attribute Types Searchable

Attributes can enable custom search functionality for objects. To make an attribute type searchable, ensure a valid search index definition exists in the attribute type controller. For instance, for a 'Property Location' attribute, the search index definition might be:

protected $searchIndexFieldDefinition = array('type' => 'integer', 'options' => array('default' => 0, 'notnull' => false));

This definition stores integer values in the search index. Implement getSearchIndexValue in the controller to insert the correct data into the index:

public function getSearchIndexValue() {
    // Implementation details...
}

For the search interface, create a search.php file in the attributes/property_location folder with a select menu. Implement the search() and searchForm() methods in the controller for frontend interaction and backend searching:

public function search() {
    // Implementation details...
}

public function searchForm($list) {
    // Implementation details...
}

Searching against Multiple Fields

To search against multiple fields, define multiple index fields in the searchIndexFieldDefinition array:

protected $searchIndexFieldDefinition = array(
    // Multiple field definitions...
);

Adjust the searchForm() method to handle multiple search fields:

public function searchForm($list) {
    // Implementation details...
}

Handling Search via Keyword

To enable keyword search, implement the searchKeywords() method in the controller:

public function searchKeywords($keywords, $queryBuilder) {
    // Implementation details...
}

This method uses the QueryBuilder object to perform keyword-based searches on the attribute data.