(thanks to @jordanlev in this thread on the concrete5 forums)
Concrete5 allows developers to create custom attributes designed to take in and process particular types of information which can then be associated with a page, file or user. This powerful framework allows for a great deal of extensibility and customised functionality.
Here I’m going to build a custom page attribute which allows the user to select from a checkbox list of other pages on the site. Why is this useful? Well, out-of-the-box, concrete5′s page categorisation architecture is limited mostly to the site’s hierarchy. In other words, a page’s ‘category’ is defined by its parent page within the site’s tree. This is really sensible and caters for a great deal of relatively simple site architectures. But what if you have a page type which you’d like to associate with more than one parent page? For example, you might have a main News page with multiple news articles beneath it. But those news articles might relate to the project pages under your top-level Projects hub, so it might be useful to associate the appropriate news articles with the relevant project pages, with a page list on the latter to display those relevant articles. A page selector attribute provides a simple and elegant way of achieving this.
1. Create the attribute's controller
Create a file controller.php at /models/attribute/types/page_selector and paste in the following code (see comments):
<?php defined('C5_EXECUTE') or die(_('Access Denied.'));
Loader::model('attribute/types/default/controller');
/*
* Page selector attribute
*
* Grabs a list of pages of a particular type.
* Separate attributes are used to extend this class
* and specify the correct page type handle using the
* setHandle() method.
*
*/
class PageSelectorAttributeTypeController extends DefaultAttributeTypeController {
/*
* Setup attribute form
*/
public function form() {
$this->set('fieldPostName', $this->field('value'));
// Get all possible options (cIDs and collection names)
$options = $this->getAvailablePages();
$this->set('options', $options);
// Get currently selection values (page IDs)
$selected = is_object($this->attributeValue) ? $this->getAttributeValue()->getValue() : '';
$selected = explode("\n", trim($selected));
$this->set('selected', $selected);
$availablePages = $this->getAvailablePages();
$this->set('availablePages', $availablePages);
$this->set('fieldPostName', $this->field('value'));
}
/*
* Get available pages using handle provided handle
*/
public function getAvailablePages() {
Loader::model('page_list');
$pl = new PageList;
// Check for external handle or set manually
// (1) You can filter by a particular page type handle by specifying it below, or use this attribute as
// a parent class for a set of child classes which specify the page type handle to filter by
// on an ad-hoc basis (this means there is one central controller containing the attribute's logic,
// which you can extend with further skeleton page selector attributes to provide page selectors for
// different page type handles
// (2) To remove the filter, so all the site's pages are listed, comment out the next 3 lines
//$ext_handle = $this->setHandle();
$handle = ($ext_handle == '') ? 'define_handle' : $ext_handle;
$pl->filterByCollectionTypeHandle($handle);
// Get the (filtered, if specified above) list of pages
$pages = $pl->get();
$retArray = array();
foreach ($pages as $page) {
$retArray[$page->getCollectionID()] = $page->getCollectionName();
}
asort($retArray);
return $retArray;
}
/*
* Serialise selected page IDs and save to DB
*/
public function saveForm($data){
$valueArray = $data['value'];
// Using the manual serialization method rather than serialize() to
// make searching more straightforward
$valueString = implode("\n", $valueArray);
if (empty($valueString)) {
$this->saveValue('');
} else {
$this->saveValue("\n{$valueString}\n");
}
}
/*
* Pass available options to search.php
*/
public function search() {
$this->set('fieldPostName', $this->field('value'));
$options = $this->getAvailablePages();
$this->set('options', $options);
}
/*
* Derives page names from DB-stored page IDs for user-friendliness
*/
public function getDisplayValue() {
$results = $this->getValue();
$results = explode("\n", trim($results));
$string = '';
foreach ($results as $result) {
$page = Page::getByID($result);
$string .= $page->getCollectionName() . '<br/>';
}
$string = substr($string, 0, strlen($string)-2);
return $string;
}
/*
* Provides search functionality for concrete5's Sitemap Page Search
* This function is optional -- the attribute will work without it, but
* the Sitemap Page Search won't work. Requires search.php.
*/
public function searchForm($list) {
$terms = $this->request('value');
$tbl = $this->attributeKey->getIndexedSearchTable();
// If no options are set, return an unfiltered list of the site's pages,
// otherwise build the DB search query, filter the site's pages by it
// and return the result
if (!is_array($terms)) {
return $list;
} else {
$searchString = "(";
foreach ($terms as $term) {
$searchString .= $tbl . ".ak_" . $this->attributeKey->getAttributeKeyHandle() . " LIKE '%\n{$term}\n%' OR ";
}
$searchString = substr( $searchString, 0, (strlen($searchString)-4) );
$searchString .= ")";
$list->filter(false, $searchString);
//$list->debug();
return $list;
}
}
}
2. Create the attribute's form
The attribute’s form defines the interface that will be displayed in a page’s properties when this attribute is added. It handles the interface both when adding a fresh instance of the attribute and when editing an existing one. This file is the front-end to the form() function in controller.php, which provides it with a list of selectable pages, and a list of currently selected pages (if applicable to the current page).
Create the form by pasting the following code into /models/attribute/types/page_selector/form.php:
<?php defined('C5_EXECUTE') or die("Access Denied.");
$form = Loader::helper('form');
// Arrays of (1) possible pages, and (2) currently selected pages
$options_array = array();
$selected_ids = array();
// If $selected is an array there must be pages selected already -- get their IDs
if (is_array($selected)) {
foreach($selected as $key => $value){
$selected_ids[] = $value;
}
}
// If there is at least 1 page that can be selected, display a list. Otherwise warn the user that
// there are no options to choose from. This is determined by the filtering methods applied to
// the pagelist object in getAvailablePages() in controller.php
if (count($options) > 0) {
echo '<fieldset>';
// Loop through available options and output checkboxes, setting to checked where page IDs
// match those in the array of selected page IDs
foreach ($options as $key=>$option) {
$selected = '';
if ( in_array($key, $selected_ids)) $selected = ' checked';
?>
<label class="checkbox inline">
<input type="checkbox" name="<?=$fieldPostName?>[]" value="<?=$key?>"<?=$selected?> />
<?=$option?>
</label>
<?php
}
echo '</fieldset>';
} else {
echo '<strong style="line-height:30px">No options have been defined yet.</strong>';
}
That’s it — the attribute should function as expected once added in the Dashboard.
Here are a couple of enhancements to improve the page selector’s functionality and extensibility.
3. Searching the page selector attribute’s values
First, if you want to be able to search the attribute’s values from the Sitemap’s Page Search page, you’ll need to (1) include the searchForm() function in controller.php, and (2) include the following code in /models/attribute/types/page_selector/search.php, which defines the interface to show in the Page Search form:
<?php
defined('C5_EXECUTE') or die("Access Denied.");
/*
* Loop through the available options and output checkboxes
*/
foreach ($options as $key=> $option) {
?>
<label class="checkbox inline" style="margin:2px 0; float: none; display: block">
<input type="checkbox" name="<?=$fieldPostName?>[]" value="<?=$key?>"/> <?=$option?>
</label>
<?php
}
Pretty simple!
- Abstracting the attribute’s code
If you only plan to have one page selector attribute, the above code will work perfectly well. But the project in which I was using this code required multiple selector attributes which filtered by different page types (e.g. news articles, events, projects). One way to achieve this is simply (1) to duplicate the /models/attribute/types/page_selector directory, (2) change the class name in controller.php from PageSelectorAttributeTypeController to AnotherPageSelectorAttributeTypeController and (3) alter the filters in getAvailablePages() to filter out a different page type.
This works, but it doesn’t respect the DRY principle, and although the code involved is pretty simple and straightforward, it’s not great from a maintainability point of view.
One solution is therefore to create a generic page selector attribute (as the above code demonstrates), then a set of “sub-attributes” which feed the correct page type filter to the parent class. The hook for this is already in place in the code above in the getAvailablePages() function. When uncommented, this line
//$ext_handle = $this->setHandle();
attempts to get a handle from the setHandle() function. Now we’ll create that function in a sub-attribute, so we can pass arbitrary values to the parent class and execute the same controller code regardless of how many page selector attributes we’ve created.
Create a new sub-attribute by pasting the following code into /models/attribute/types/another_page_selector/controller.php:
<?php defined('C5_EXECUTE') or die(_('Access Denied.'));
class AnotherPageSelectorAttributeTypeController extends PageSelectorAttributeTypeController {
/*
* Set the page type handle for this attribute
*/
public function setHandle() {
return 'another_page_type';
}
}
That’s all there is to the controller. Because this new class (AnotherPageSelectorAttributeTypeController) is extending PageSelectorAttributeTypeController, the latter has access to all the functions defined in the former. So when the core page selector attribute controller attemps to execute setHandle(), in this case it’ll get the value ‘another_page_type’, which it’ll use to filter out the site’s pages. Note the conventions for using CamelCase in the class names, and how this relates to the use of underscores in file names (so a controller class called NewsArticleAttributeTypeController should live in /models/attribute/types/news_article/). For more info see this article on the concrete5 website.
Another note — if you go down this sub-attribute route, you need to copy search.php and form.php from the original Page Selector directory (/models/attribute/types/page_selector/) to the new sub-attribute’s directory, and to any other sub-attributes you create. Once you’ve done this you can safely delete form.php and search.php from the original Page Selector’s directory.
5. Using the attribute in practice
As mentioned above, a practical use for this attribute might be to associate pages to one another which otherwise don’t have any hierarchical relevance. The following code might be placed on a project page to automatically filter out news articles which, by using the page selector attribute, have been associated to it:
<aside class="news">
<?php
$c = Page::getCurrentPage();
$c_id = $c->getCollectionID();
Loader::model('page_list');
$nh = Loader::helper('navigation');
$pl = new PageList;
// First filter by the correct page type, to narrow the scope down first
$pl->filterByCollectionTypeHandle('news_article');
// Filter by the page selector attribute
// (1) ak_page_selector matches the attribute's handle, e.g. ak_news_selector, ak_project_selector etc
// (2) $c_id is the page ID that should be searched for. In this instance we're looking for news
// articles associated with the current page, so we pass in this page's ID
$pl->filter(false, "(ak_page_selector LIKE '%\n{$c_id}\n%')");
$relevant_news = $pl->get();
// Outputs a list of relevant news article titles and links
echo '<ul>';
foreach ($relevant_news as $news_article) {
<li><?php echo htmlspecialchars($page->getCollectionName(), ENT_QUOTES, APP_CHARSET); ?></li>
<li><a href="<?php echo $nh->getLinkToCollection($news_article);?>">Read more ></a></li>
}
echo '</ul>';
Obviously the full flexibility of the Page List and Collection objects are available to you when deciding how to output the results.