This is the documentation for concrete5 version 5.6 and earlier. View Current Documentation

By Andrew Embler, concrete5 CTO.

concrete5 ships with a number of attribute types, from simple ones like number and boolean to complex types like address and rating. While these attribute types will be sufficient for many sites, from time to time developers will need to extend and add to these types. This guide should show you how.

Note: this guide is focused on solving one real-world problem. For the complete guide to how attribute types work, and a full API guide to attribute types, head here

A Real World Use Case

In a thread, the user okhayat describes a modification to the concrete5 eCommerce system. Basically, he needs to simplify the address type: it should consist of a single textarea and a custom list of cities, but is finding eCommerce to be inflexible (e.g. the eCommerce addon assumes the presence of one billing_address attribute, of the type address, etc...)

Rather than hack the checkout pages and the eCommerce system itself, let's approach the problem differently. Let's create a new attribute type, based on the address attribute, that will support okhayat's different presentation and validation requirements, while remaining backward compatible, API-wise, so the eCommerce can work with it.

In this tutorial we're going to do just that. We'll create a new address type, based on address, named "okhayat_address".

Setup your Environment

You'll need to create a directory named "attribute" in the models/ directory, at the root of your concrete5 install. Within this directory, create a "types" directory, From the root of your concrete5 install, the path to this directory should be

models/attribute/types/

This is your website's local attribute types directory, and will be referred to as such from within this tutorial.

Creating the Attribute Types Directory

Since we're basing our attribute type on the address attribute type, let's start by copying the address attribute type into the directory we just created. Instead of naming it address, let's rename it to "okhayat_address." Now we should have

models/attribute/types/okhayat_address

Removing Cruft

First, let's remove the items we don't need. Since this attribute type is simpler than the typical address attribute, we don't need the following files in it:

country_state.js type_form.php type_form.js

These are all files which help the address attribute type do complex things like automatically filter provinces when a country is changed, or only allow certain countries/provinces to be selected. Since this attribute type doesn't need that functionality, these files should be removed.

Add db.xml

The db.xml is an XML representation of any database tables required by the discount type. This file will automatically be parsed and executed when the attribute type is installed. This file is in the AXMLS format. It is widely used throughout concrete5. Since we need a custom database table to store our address information, we should provide a db.xml file in our attribute type directory. Let's use this one:

<?xml version="1.0"?>
<schema version="0.3">
<table name="atOkhayatAddress">
    <field name="avID" type="I" size="10">
      <KEY></KEY>
      <DEFAULT value="0"></DEFAULT>
      <UNSIGNED></UNSIGNED>
    </field>
    <field name="address" type="X" ></field>
    <field name="city" type="C" size="255"></field>
</table>
</schema>

Notice that this differs from the traditional atAddress table. It's name is different, and it specifies just one address field, of the X type (which means it will be large, text type column.) City is also included, but nothing else.

Modify form.php

This file is displayed whenever the attribute type is shown in form form. For example, when someone is saving their address, this script will be shown. Remove the existing script, and replace it with this:

<? defined('C5_EXECUTE') or die(_("Access Denied.")); ?>
<? $f = Loader::helper('form'); ?>

<div class="ccm-attribute-address-line">
<?=$f->label($this->field('address'), t('Address'))?>
<?=$f->textarea($this->field('address'), $address)?>
</div>
<div class="ccm-attribute-address-line">
<?=$f->label($this->field('city'), t('City'))?>
<?=$f->select($this->field('city'), array(
    'city1' => 'City 1',
    'city2' => 'City 2',
    'city3' => 'City 3'
), $city);
?>
</div>

This is a simple example. Basically, we're allowing the user to choose their address, and their city. Notice that the cities themselves are not populated dynamically (they're not in valid cities - that will be up to you.)

Modify controller.php

First, rename the controller classes appropriately. Within the file you'll find

class AddressAttributeTypeController extends AttributeTypeController {
...
class AddressAttributeTypeValue extends Object {

Modify these lines so that the new classes extend the existing ones

class OkhayatAddressAttributeTypeController extends AddressAttributeTypeController {
...
class OkhayatAddressAttributeTypeValue extends AddressAttributeTypeValue {

Now, let's go through a method-by-method description of the controller, and show how we convert the existing address attribute type to our new custom one.

searchKeywords

public function searchKeywords($keywords) {
    $db = Loader::db();
    $qkeywords = $db->quote('%' . $keywords . '%');
    // todo make this less hardcoded (with ak_ in front of it)
    $str = '(ak_' . $this->attributeKey->getAttributeKeyHandle() . '_address1 like '.$qkeywords.' or ';
    $str .= 'ak_' . $this->attributeKey->getAttributeKeyHandle() . '_address2 like '.$qkeywords.' or ';
    $str .= 'ak_' . $this->attributeKey->getAttributeKeyHandle() . '_city like '.$qkeywords.' or ';
    $str .= 'ak_' . $this->attributeKey->getAttributeKeyHandle() . '_state_province like '.$qkeywords.' or ';
    $str .= 'ak_' . $this->attributeKey->getAttributeKeyHandle() . '_postal_code like '.$qkeywords.' or ';
    $str .= 'ak_' . $this->attributeKey->getAttributeKeyHandle() . '_country like '.$qkeywords.' )';
    return $str;
}

This method is run when keywords are searched against this attribute type. Change it to this:

public function searchKeywords($keywords) {
    $db = Loader::db();
    $qkeywords = $db->quote('%' . $keywords . '%');
    // todo make this less hardcoded (with ak_ in front of it)
    $str = '(ak_' . $this->attributeKey->getAttributeKeyHandle() . '_address like '.$qkeywords.' or ';
    $str .= 'ak_' . $this->attributeKey->getAttributeKeyHandle() . '_city like '.$qkeywords.' or ';
    return $str;
}

What's the difference? We're now only searching address and city (and there is no longer address1 and address2 - instead there's just address.)

searchForm

Additionally, change the entire content of the searchForm method to this:

public function searchForm($list) {
    $address = $this->request('address');
    $city = $this->request('city');
    if ($address) {
        $list->filterByAttribute(array('address' => $this->attributeKey->getAttributeKeyHandle()), '%' . $address . '%', 'like');
    }
    if ($city) {
        $list->filterByAttribute(array('city' => $this->attributeKey->getAttributeKeyHandle()), '%' . $city . '%', 'like');
    }
    return $list;
}

This is the method that is run when the advanced search form for this attribute type used and then searched against.

searchIndexFieldDefinition

Change the $searchIndexFieldDefinition array to this:

protected $searchIndexFieldDefinition = array(
    'address' => 'X NULL',
    'city' => 'C 255 NULL',
);

What's this doing? This ensures that when this attribute is indexed by concrete5, the correct columns and correct types are used.

search, saveForm, validateForm

public function search() {
    print $this->form();
    $v = $this->getView();
    $this->set('search', true);
    $v->render('form');
}

public function saveForm($data) {
    $this->saveValue($data);
}

public function validateForm($data) {
    return ($data['address'] != '' && $data['city'] != '');
}

We make some slight changes to search, no changes to saveForm, and we make validateForm (which is run when the field is validated) refer only to address and city.

getSearchIndexValue

We change this method to be reflect our smaller column size

public function getSearchIndexValue() {
    $v = $this->getValue();
    $args = array();
    $args['address'] = $v->getAddress();
    $args['city'] = $v->getCity();
    return $args;
}

deleteKey, deleteValue

These are run when an attribute and/or its values are removed. We change these methods to reference our new database table:

public function deleteKey() {
    $db = Loader::db();
    $arr = $this->attributeKey->getAttributeValueIDList();
    foreach($arr as $id) {
        $db->Execute('delete from atOkhayatAddress where avID = ?', array($id));
    }
}
public function deleteValue() {
    $db = Loader::db();
    $db->Execute('delete from atOkhayatAddress where avID = ?', array($this->getAttributeValueID()));
}

saveValue

Run when a value is saved, we reference a new database table and a smaller column set:

public function saveValue($data) {
    $db = Loader::db();
    if ($data instanceof OkhayatAddressAttributeTypeValue) {
        $data = (array) $data;
    }
    extract($data);
    $db->Replace('atOkhayatAddress', array('avID' => $this->getAttributeValueID(),
        'address' => $address,
        'city' => $city
        ),
        'avID', true
    );
}

getValue, getDisplayValue

public function getValue() {
    $val = OkhayatAddressAttributeTypeValue::getByID($this->getAttributeValueID());     
    return $val;
}

public function getDisplayValue() {
    $v = $this->getValue();
    $ret = nl2br($v);
    return $ret;
}

We make sure to reference the OkhayatAddressAttributeTypeValue class, and we leave getDisplayValue unchanged.

Remove action_load_provinces_js(), validateKey, duplicateKey, saveKey, type_form

Since our attribute type is much simpler than the typical address attribute type, we don't need these methods. Remove them.

load()

public function form() {
    if (is_object($this->attributeValue)) {
        $value = $this->getAttributeValue()->getValue();
        $this->set('address', $value->getAddress());
        $this->set('city', $value->getCity());
    }
}

OkhayatAddressAttributeTypeValue class

First, make sure this class extends AddressAttributeTypeValue.

Now, change it so it looks like this:

public static function getByID($avID) {
    $db = Loader::db();
    $value = $db->GetRow("select avID, address, city from atOkhayatAddress where avID = ?", array($avID));
    $aa = new OkhayatAddressAttributeTypeValue();
    $aa->setPropertiesFromArray($value);
    if ($value['avID']) {
        return $aa;
    }
}

public function getAddress() {return $this->address;}
public function getCity() {return $this->city;}

public function __toString() {
    $ret = '';
    if ($this->address) {
        $ret .= $this->address . "\n";
    }
    if ($this->city) {
        $ret .= $this->city;
    }
    return $ret;        
}

Install the Attribute Type on your Site

Finally, when your attribute type is written and the various files present within the directory are completed (or at the very least ready to be tested) you'll need to install your attribute type to make the eCommerce addon aware of it. Head to Dashboard > Settings > Attributes. Then, click the "Manage Attribute Types" link.

Now, you should see the Okhayat_Address type listed below the currently installed attribute types. Install it, then make sure that it is available for any and all attribute categories that should have access to it. For our purposes, we'd make sure that the eCommerce order and Users categories have access to the attribute type.

Change address types to use the new type

Finally, when using this in an eCommerce installation, you navigate to the order attributes and user attributes areas, and delete any address types. Then, re-add the address types with the same handle (e.g. "billing_address", "shipping_address") but make sure that they're using the new attribute type. At that point, when checking out or searching or interacting with the attributes in any way, the new attribute type will be used.

Loading Conversation