This is the documentation for concrete5 version 5.6 and earlier. View Current Documentation
<script type="text/javascript">
toggleFAQEntry = function(item) {
    var desc = $('#fa' + item);
    if (desc.css('display') == 'none') {
        desc.fadeIn(200);
    } else {
        desc.hide();
    }
}
</script>

By Andrew Embler, CTO of concrete5. Also available at andrewembler.com

One of concrete5's greatest strengths is the way that its pages support in-context editing. This mode is not an afterthought or a bolted-on feature; it is the way that concrete5 has always worked. Site administrators need only visit a particular page, put the page in edit mode, and click and edit to make changes to a page. Changes are made instantaneously. No administrative panel needs to be understood out of the gate.

However, there are times when this approach is not the best way to manage content. For example, when adding pages concrete5 presents itself much like a blank canvas. While this freedom is powerful, certain applications require a bit more structure, and concrete5's approach may take longer than necessary. The solution isn't to ignore concrete5's built-in objects like pages, attributes or page types, but instead to craft a custom interface for creating these pages in a more efficient manner. This tutorial will show you how to do just that.

Required Version

While this tutorial uses screenshots from concrete 5.4.0.5, its approach should work on concrete 5.3.3.1 and above.

Download Code

If you want to skip the tutorial and download the code, you can do so from here.

http://www.concrete5.org/files/2012/7309/4756/example_faq_code.zip (10 KB)

This code is also available as a free add-on in our marketplace.

http://www.concrete5.org/marketplace/addons/example-faq/

Components

I'm going to show you how to build a simple FAQ system for your website. Here are the components of it:

Multiple FAQ Sections

Using a custom attribute, administrators can specify different sections of their site as possible FAQ Entry holders. 

footer image

Dashboard Add/Edit Interface

Administrators will be able to use a simplified dashboard interface to add FAQ Entries beneath these categories. 

These FAQ Entries can be pages of any type, leaving their presentation very flexible. Since they are pages, they can be returned in search results and have additional blocks attached to them.

footer image

Dashboard List/Filter Interface

The dashboard single page interface lists all FAQ entries added to the site, and allows filtering by section:

Custom Page List

A custom page list template allows administrators to display these pages in an attractive, FAQ-esque way.

footer image

Specifying FAQ Sections

The Example FAQ code detailed here uses a custom attribute to determine where FAQs can be added to a particular website. You'll need to create this attribute, then set it to true at one or more pages in your site. Then, those pages will show up as places where FAQ entries may be added.

Create and Assign Required Custom Attributes

From Dashboard > Pages & Themes > Attributes, create a checkbox attribute with the handle "faq_section"

Now, create a select element with the handle faq_tags. (Note: this attribute isn't used in any of our custom views - it is simply here to detail how you can include custom attribute forms in your single pages.)

http://andrewembler.com/files/6912/7309/6093/picture 11.png

Finally, assign the FAQ Section attribute to at least one page in your site.

footer image

Creating the Single Page

Our dashboard interface for listing and editing FAQ entries will be found at

http://www.yoursite.com/index.php/dashboard/example_faq/

The first thing we'll want to do is register this URL with concrete5. To do so, first we create code files for the single page's view layer, and its controller. These files can be empty at first – they just need to exist.

Create the View Layer

Within your site's single_pages/ directory, create a folder named "dashboard," then a folder named "example_faq", and within it, place an empty view.php file.

footer image

Create the Controller

Within your site's controllers/ directory, create the same structure, but instead of creating a file named view.php, name it controller.php

footer image

Add Controller Code to Your Controller

Some code needs to be added to your controller file before we can proceed with the installation of your new single page. Add this code to your empty controller file:

<?php
class DashboardExampleFaqController extends Controller {

}

This code simply tells concrete5 the name of the controller class. It must be named and capitalized this way! (Note: capitalization is important.) Simply take the path of the directories you're using, replace any underscores with additional punctuation, and add Controller on the end of the class. Make sure it your class extends the Controller class.

Install the Single Page

Now that the code files are in their proper place, you need to register the single page with concrete5 before it shows up in the dashboard. This is done in the Dashboard > Pages & Themes > Single Pages area of your dashboard. Simply scroll to the bottom and enter the path to your new single page in the form field:

footer image

Submit the form, and your page should show up in the dashboard in the left menu. Click on it, and you should have a blank page in the dashboard available to you.

Controller Code

Now it's time to populate the DashboardExampleFaqController class with real code.

Setup

Now that you have blank controller and view files, it's time to add code to them, so that they can do things like retrieve pages, list pages, and add pages. Add the following methods to the controller.php file:

public $helpers = array('html','form');

This declaration loads the Html and Form helpers found in concrete/helpers/, and sets them in the view.php file. This means that helper objects named $html and $form, respectively, will be available. (This is simply a shorthand way of specifying which helpers are available in a controller's associated view.)

public function on_start() {
    Loader::model('page_list');
    $this->error = Loader::helper('validation/error');
}

The on_start() method fires automatically every time the associated controller is run. (Note: this is slightly different than the __construct() method - on_start() will only ever be run when the page is actually rendered as a view. Here we load the PageList class, and setup ValidationErrorHelper object within our controller.

public function view() {
    $this->loadFAQSections();
    $faqList = new PageList();
    $faqList->sortBy('cDateAdded', 'desc');
    if (isset($_GET['cParentID']) && $_GET['cParentID'] > 0) {
        $faqList->filterByParentID($_GET['cParentID']);
    } else {
        $sections = $this->get('sections');
        $keys = array_keys($sections);
        $keys[] = -1;
        $faqList->filterByParentID($keys);
    }
    $this->set('faqList', $faqList);
    $this->set('faqResults', $faqList->getPage());
}

protected function loadFAQSections() {
    $faqSectionList = new PageList();
    $faqSectionList->filterByFaqSection(1);
    $faqSectionList->sortBy('cvName', 'asc');
    $tmpSections = $faqSectionList->get();
    $sections = array();
    foreach($tmpSections as $_c) {
        $sections[$_c->getCollectionID()] = $_c->getCollectionName();
    }
    $this->set('sections', $sections);
}

The first two methods we include in our controller are responsible for loading data within the view layer. The Controller::view() method is automatically run when a particular single page is viewed. That means the moment we come to index.php/dashboard/example_faq/, our view() method is run. First, it runs the loadFAQSections() method, which sets a $sections array filled with all pages that have been marked as FAQ Sections (using the faq_section attribute detailed earlier.) Then, it creates a new PageList object that retrieves all pages within any of these sections, and sets up paging on it. Finally, the list of pages and the PageList object itself are set within the view using Controller::set(), so those variables will be accessible within view.php

Edit

The edit() function will be fired when we edit a particular FAQ entry using this single page interface (e.g. http://www.yoursite.com/index.php/dashboard/example_faq/edit/200)

public function edit($cID) {
    $this->setupForm();
    $faq = Page::getByID($cID);
    $sections = $this->get('sections');
    if (in_array($faq->getCollectionParentID(), array_keys($sections))) {
        $this->set('faq', $faq);    
    } else {
        $this->redirect('/dashboard/example_faq/');
    }
}

protected function setupForm() {
    $this->loadFAQSections();
    Loader::model("collection_types");
    $ctArray = CollectionType::getList('');
    $pageTypes = array();
    foreach($ctArray as $ct) {
        $pageTypes[$ct->getCollectionTypeID()] = $ct->getCollectionTypeName();      
    }
    $this->set('pageTypes', $pageTypes);
    $this->addHeaderItem(Loader::helper('html')->javascript('tiny_mce/tiny_mce.js'));
}

First, we run setupForm(), which takes care of loading available FAQ sections, as well as getting an array of all page types installed on a site, and adding our rich text editor JavaScript. Then, we get information about the requested FAQ Entry, validate that it appears in the proper location, and set the $faq object in the view layer.

Add/Update

The add() function is fired when the add interface is displayed and/or submitted (e.g. http://www.yoursite.com/index.php/dashboard/example_faq/add/). 

public function add() {
    $this->setupForm();
    if ($this->isPost()) {
        $this->validate();
        if (!$this->error->has()) {
            $parent = Page::getByID($this->post('cParentID'));
            $ct = CollectionType::getByID($this->post('ctID'));             
            $data = array('cName' => $this->post('faqTitle'), 
            'cDescription' => $this->post('faqDescription'), 
            'cDatePublic' => Loader::helper('form/date_time')->translate('faqDate'));
            $p = $parent->add($ct, $data);  
            $this->saveData($p);
            $this->redirect('/dashboard/example_faq/', 'faq_added');
        }
    }
}

First, we setup the form. If we're not posting the form, then no further action is required. If the form is a post, however, that means we're submitting a new FAQ Entry into our system. That means we need to validate it using the validate() function (which will be shown later.) If the validate function passes, that means we're posting valid information, so we grab the parent page specified, grab the page type specified, and add a page of the specific type. Then we call a custom saveData() method (shown later) which takes care of setting up custom attributes like tags, and body content. Then we redirect the user to http://www.yoursite.com/index.php/dashboard/example_faq/faq_added/

The update() function is run when an existing FAQ Entry is updated. It is very similar to add() but takes an existing page ID:

public function update() {
    $this->edit($this->post('faqID'));

    if ($this->isPost()) {
        $this->validate();
        if (!$this->error->has()) {
            $p = Page::getByID($this->post('faqID'));
            $parent = Page::getByID($this->post('cParentID'));
            $ct = CollectionType::getByID($this->post('ctID'));             
            $data = array('ctID' =>$ct->getCollectionTypeID(), 
                'cDescription' => $this->post('faqDescription'), 
                'cName' => $this->post('faqTitle'), 
                'cDatePublic' => Loader::helper('form/date_time')->translate('faqDate')
            );
            $p->update($data);
            if ($p->getCollectionParentID() != $parent->getCollectionID()) {
                $p->move($parent);
            }
            $this->saveData($p);
            $this->redirect('/dashboard/example_faq/', 'faq_updated');
        }
    }
}

First we run the edit function on the posted faqID variable, then we run validate. If there are no errors, we update the page, move it if necessary, and handle redirection.

Validation

protected function validate() {
    $vt = Loader::helper('validation/strings');
    $vn = Loader::Helper('validation/numbers');
    $dt = Loader::helper("form/date_time");
    if (!$vn->integer($this->post('cParentID'))) {
        $this->error->add(t('You must choose a parent page for this FAQ entry.'));
    }           

    if (!$vn->integer($this->post('ctID'))) {
        $this->error->add(t('You must choose a page type for this FAQ entry.'));
    }           

    if (!$vt->notempty($this->post('faqTitle'))) {
        $this->error->add(t('Title is required'));
    }

    if (!$this->error->has()) {
        Loader::model('collection_types');
        $ct = CollectionType::getByID($this->post('ctID'));             
        $parent = Page::getByID($this->post('cParentID'));              
        $parentPermissions = new Permissions($parent);
        if (!$parentPermissions->canAddSubCollection($ct)) {
            $this->error->add(
                t('You do not have permission to add a page of that type to that area of the site.')
            );
        }
    }
}

The validate function is run by both add() and update(), and checks the post array to ensure that the FAQ entry in question has valid properties. First, we check to ensure that a parent ID is passed through post, a valid page type ID is passed, a valid title, and then we check permissions to ensure that the user has the ability to add to the particular location. If any of these fail, we add a text error to the $this->error object we instantiated in on_start() above (more on this below.)

saveData($p)

This function is run by both add() and update(), and is responsible for saving body content, and setting custom attributes on the page in question. It is passed the page object.

private function saveData($p) {
    $blocks = $p->getBlocks('Main');
    foreach($blocks as $b) {
        $b->deleteBlock();
    }

    $bt = BlockType::getByHandle('content');
    $data = array('content' => $this->post('faqBody'));
    $p->addBlock($bt, 'Main', $data);

    Loader::model("attribute/categories/collection");
    $cak = CollectionAttributeKey::getByHandle('faq_tags');
    $cak->saveAttributeForm($p);        
}

The first bit of code is responsible for clearing out any existing blocks in the Main area. The second saves a block of type content into the Main area array, and the third is responsible for grabbing the submitting 'faq_tags' attribute form, and saving it against the page.

Error and Success Display

The following methods are those that display success and error messages to the end user.

public function faq_added() {
    $this->set('message', t('FAQ added.'));
    $this->view();
}

public function faq_updated() {
    $this->set('message', t('FAQ updated.'));
    $this->view();
}

public function on_before_render() {
    $this->set('error', $this->error);
}

The faq_added() and faq_updated() methods are automatically run when the user is redirect to http://www.yoursite.com/index.php/dashboard/example_faq/faq_added or http://www.yoursite.com/index.php/dashboard/example_faq/faq_updated/, respectively. They simply set the $message variable in the view layer, which is automatically displayed in a nice fashion by our dashboard theme. Beyond setting this variable, no further action is required. Finally, the on_before_render() function will be called immediately prior to exiting the controller and rendering the view. It sets the $error variable in the Dashboard theme. If this error object has any errors – such as those tripped by the validate() function above – they will be displayed in a nice list automatically.

View Code

The following code goes into view.php. It directly interacts with any variables set within the controller.

Create view vs. add/update sections

The same view.php file will be displayed whether a user is listing all FAQs or add/updating one. That means we need to create a single outer loop which displays certain content depending on the current task.

<? if (($this->controller->getTask() == 'update' || 
     $this->controller->getTask() == 'edit' || 
     $this->controller->getTask() == 'add')) { ?>

<? } else { ?>

<? }?>

The top half of the if statement will show in add/edit/update mode, and the bottom half will show in all other cases. We place the adding/editing form in the first section, and the listing form in the second.

Add/Edit/Update Form

This code should be pasted within the first section of the loop above.

<? 
$title = $this->controller->getTask() == 'add' ? t('Add') : t('Update');
$df = Loader::helper('form/date_time');

if (is_object($faq)) { 
    $faqTitle = $faq->getCollectionName();
    $faqDescription = $faq->getCollectionDescription();
    $faqDate = $faq->getCollectionDatePublic();
    $cParentID = $faq->getCollectionParentID();
    $ctID = $faq->getCollectionTypeID();
    $faqBody = '';
    $eb = $faq->getBlocks('Main');
    if (is_object($eb[0])) {
        $faqBody = $eb[0]->getInstance()->getContent();
    }
    $task = 'update';
    $buttonText = t('Update Entry');
} else {
    $task = 'add';
    $buttonText = t('Add FAQ Entry');
}

?>

<div style="width: 760px">

<h1><span><?=t('FAQs')?></span></h1>
<div class="ccm-dashboard-inner">

<h2><span><?=$title?> FAQ Entry</span></h2>

<form method="post" action="<?=$this->action($task)?>" id="faq-form">
<? if ($this->controller->getTask() != 'add') { ?>
    <?=$form->hidden('faqID', $faq->getCollectionID())?>
<? } ?>

<strong><?=$form->label('faqTitle', t('Question'))?></strong>
<div><?=$form->text('faqTitle', $faqTitle, array('style' => 'width: 730px'))?></div>
<br/>
<strong><?=$form->label('faqDescription', t('Brief Answer'))?></strong>
<div><?=$form->textarea('faqDescription', $faqDescription, 
          array('style' => 'width: 730px; height: 100px'))?></div>
<br/>           
<strong><?=$form->label('cParentID', t('Section'))?></strong>
<? if (count($sections) == 0) { ?>
    <div><?=t('No sections defined. 
        Please create a page with the attribute "faq_entry" set to true.')?></div>
<? } else { ?>
    <div><?=$form->select('cParentID', $sections, $cParentID)?></div>
<? } ?>


<strong><?=$form->label('ctID', t('Page Type'))?></strong>
<div><?=$form->select('ctID', $pageTypes, $ctID)?></div>
<br/>
<strong><?=$form->label('faqDate', t('Date/Time'))?></strong>
<div><?=$df->datetime('faqDate', $faqDate)?></div>
<br/>
<strong><?=t('Full Answer')?></strong>
<?php Loader::element('editor_init'); ?>
<?php Loader::element('editor_config'); ?>
<?php Loader::element('editor_controls', array('mode'=>'full')); ?>
<?=$form->textarea('faqBody', $faqBody, 
      array('style' => 'width: 100%; height: 150px', 'class' => 'ccm-advanced-editor'))?>
<br/>
<? 
Loader::model("attribute/categories/collection");
$akt = CollectionAttributeKey::getByHandle('faq_tags');
if (is_object($faq)) {
    $tvalue = $faq->getAttributeValueObject($akt);
}
?>
<div class="faq-attributes">
    <div>
        <strong><?=$akt->render('label');?></strong>
        <?=$akt->render('form', $tvalue, true);?>
    </div>
</div>

<br/>

<?

$ih = Loader::helper('concrete/interface');
print $ih->button(t('Cancel'), $this->url('/dashboard/example_faq/'), 'left');
print $ih->submit($buttonText, 'faq-form');
?>
<div class="ccm-spacer"> </div>

</form>

</div>
</div>

This code is responsible for creating the add/update interface. It is unified for both add and update, meaning that if a valid $faq object is present, the fields will be loaded in, otherwise they will be blank. This code provides good examples of using our form helper to create textarea and text elements, loading the rich text editor, and rendering the form view for a custom attribute. While we include just the FAQ Tags custom attribute, any number and any type of custom attribute can be included in a single page interface. The code provided here will display any of the attribute types, and save them properly against the page automatically.

Finally, we load the concrete interface helper to present submit and cancel buttons to the user in the concrete5 style.

View List

The second bit of code in view.php is responsible for listing all existing FAQs. Paste this code within the second half of the loop created above.

<h1><span><?=t('FAQs')?></span></h1>
<div class="ccm-dashboard-inner">
<h2><?=t('New FAQ')?></h2>
<a href="<?=$this->action('add')?>"><?=t('Click here to add a new FAQ Entry >')?></a>
<Br/><br/>

<h2><?=t('View/Search FAQs')?></h2>

<form method="get" action="<?=$this->action('view')?>">
<?
$sections[0] = '** All';
asort($sections);
?>

<strong><?=$form->label('cParentID', t('Section'))?></strong>
<div><?=$form->select('cParentID', $sections, $cParentID)?>
<?=$form->submit('submit', 'Search')?>
</div>
</form>
<br/>
<?
$nh = Loader::helper('navigation');
if ($faqList->getTotal() > 0) { 
    $faqList->displaySummary();
    ?>

<table border="0" class="ccm-results-list" cellspacing="0" cellpadding="0">
    <tr>
        <th class="<?=$faqList->getSearchResultsClass('cvName')?>">
            <a href="<?=$faqList->getSortByURL('cvName', 'asc')?>"><?=t('Name')?></a>
        </th>
        <th class="<?=$faqList->getSearchResultsClass('cDateAdded')?>">
            <a href="<?=$faqList->getSortByURL('cDateAdded', 'asc')?>"><?=t('Date Added')?></a>
        </th>
        <th class="<?=$faqList->getSearchResultsClass('cvDatePublic')?>">
            <a href="<?=$faqList->getSortByURL('cvDatePublic', 'asc')?>"><?=t('Public Date')?></a>
        </th>
        <th><?=t('Page Owner')?></th>
        <th> </th>
    </tr>
    <?
    foreach($faqResults as $cobj) { ?>
    <tr>
        <td><a href="<?=$nh->getLinkToCollection($cobj)?>">
             <?=$cobj->getCollectionName()?></a>
        </td>
        <td><?=$cobj->getCollectionDateAdded()?></td>
        <td><?=$cobj->getCollectionDatePublic()?></td>
        <td>
            <? 
            $user = UserInfo::getByID($cobj->getCollectionUserID());
            print $user->getUserName();
            ?>
        </td>
        <td><A href="<?=$this->url('/dashboard/example_faq', 'edit', 
              $cobj->getCollectionID())?>"><?=t('Edit')?></a>
        </td>
    </tr>
    <? } ?>

    </table>
    <br/>
    <?
    $faqList->displayPaging();
} else {
    print t('No FAQ entries found.');
}
?>
</div>

Here we can see links to the add interface, a list for filtering, and finally a table that shows all selected pages and offers some basic sorting.

A Custom Page List Template

Finally, let's create a custom FAQ-styled template for the Page List block. That way, anywhere we add the Page List block, we'll be able to style it like this:

footer image

By clicking on the title of a FAQ entry, the short description will display, along with a link to the full answer.

Create a Custom Template Directory

In the root of your site, create a directory named blocks/page_list/templates/example_faq_page_list/, and within it a file named view.php

footer image

The following code goes into view.php

<?php

if (count($cArray) > 0) { ?>

<table border="0" cellspacing="0" cellpadding="0">
<? foreach($cArray as $cobj) { ?>

<tr id="f<?=$cobj->getCollectionID()?>">
    <td valign="top">
        <div style="width: 20px; text-align: center"><strong>Q.</strong></div>
    </td>
    <td valign="top" style="width: 100%">
        <a href="javascript:void(0)" onclick="toggleFAQEntry('<?=$cobj->getCollectionID()?>')">
            <?=$cobj->getCollectionName()?>
        </a>
    </td>
</tr>
<tr id="fa<?=$cobj->getCollectionID()?>" style="display: none">
    <td valign="top"><div style="width: 20px; text-align: center">
            <strong style="color: #666">A.</strong></div></td>
    <td>
    <div class="faq-entry-description">
        <?=$cobj->getCollectionDescription()?>
        <a href="<?=$nh->getLinkToCollection($cobj)?>" 
            style="font-weight: bold">Read More ></a>
    </div>
    </td>
</tr>
<tr>
    <td colspan="2"><br/></td>
</tr>

<? } ?>

</table>

<? } ?>

Followed by

This is simply a repurposed view.php from the existing core Page List block. Instead of showing the description, we keep the description hidden and create a custom JavaScript method that fires when clicking on the FAQ question.

Add a Page List and Apply the Custom Template

Add the Page List block on your various FAQ Section pages, configure them to show their child pages, and then assign this custom template to them. 

footer image

And that's it!

Conclusion

At this point you should see how easy it is for a developer to create a fully interactive, full-featured editing interface for pages, using the concrete5 single page approach. Your administrative users will be able to add and edit FAQ entries without interacting with the front-end interface at all. Pages are still created, meaning that they can be moved, updated, tweaked and customized using existing concrete5 editing interfaces, themes and add-ons. 

Download

Click here to download the code found in this example. (10 KB)

This code is also available as free add-on in our marketplace

http://www.concrete5.org/marketplace/addons/example-faq/

Loading Conversation