Using custom objects and lists to create concrete5 dashboard pages and front end search pages

I mentioned over the last few posts that I had learned a lot about how custom item lists work in concrete5, here's how to make a dashboard search / add / edit / delete interface for your own custom database rows. Since not everything in concrete5 needs to be a page, I'm also showing you how to make a custom front end filter page for these database items that keeps unique pretty URLs for each item.  Sample code is included.

In order to really understand this, I'm assuming that you have knowledge of how the concrete5, including how the package system works, and a fairly decent knowledge of object oriented programming in php.  I'm probably not going to be able to make it simple enough for anyone and everyone to understand, but I will try.

Overall Package Structure

It's not the easiest thing to do to create this application, but there are a lot of built in functions and classes in concrete5 that will help make things easier.  I guess the first thing to look at is the overall structure of the package. It's a lot of files, but each one is necessary, and each one makes things work in the overall application.

Pages

We have controllers and single pages for a "custom_objects" section in the dashboard.  Then we have a controller and a view for a "custom_object_list" page type.  A css file for that page type.

Elements

Elements are basically include files that you can pass parameters to.  For this, we have editor_config and editor_init which control the display of the WYSIWYG editor for the content section of our custom objects.  These are taken from the content block, and will check the home page to find out what theme it is using to apply the themes from typography.css to our dashboard edit form. 

We also have a section for all of our custom objects related items.  There's a frontend display, used on the custom_object_list page type.  A search form, used in the dashboard search / list view.  And a search results element which will show the results from that search form on the dashboard search page.

Models

Here we only need two objects.  One is the model for saving / getting data for our custom object, the other is responsible for listing and searching through those objects.  We'll get into them more later on.

Tools

We only have one object here, which is used to display the search results on the dashboard page.  It basically gets the results from the request and then includes the search_results element to display them.  It's called via ajax by the built in custom search results javascript.

Extra Files

The other two files that we have are a db.xml file to create our database schema, and a controller file to install everything for the application.

What Kind of Objects Are We Creating?

I tried to keep it really simple, just a title and a text area.  Since I'm using the WYSIWYG editor that's built in, there are a lot of extra functions in our /models/custom_object.php file that are related to translating the content from edit mode to view mode.  This is because concrete5 uses some shorthand notation and IDs to create the links to pages and file downloads and images instead of linking to the file name.  I'm not going to go into the details of these here, but the model source code should be enough for you to go from.  You'd have to replace all instances of "CustomObject" in the callback functions with the name of your actual object if you modify it.

This is the database schema that we are working with:

  1. <?xml version="1.0"?>
  2. <schema version="0.3">
  3. <table name="customObjects">
  4. <field name="coID" type="I">
  5. <key />
  6. <unsigned />
  7. <autoincrement />
  8. </field>
  9. <field name="title" type="C" size="255">
  10. </field>
  11. <field name="content" type="X2">
  12. </field>
  13. </table>
  14. </schema>

Sometimes you want something much simpler than a page in a concrete5 website.  Typically, the smallest unit that you have is a page, but often it's actually better to just make a form to update a simple database row and then search and filter off of that.  The problem with this is that you have a lot of extra stuff when you add a page, sometimes you just want a simple database row but still want a custom url to view that particular database row.  By creating the custom_object_list page type and controller, we can override the view function to take a parameter for what custom object we want to display, then use that to change the output of the page to display that particular object.  To the end user, it looks like you have a lot of different pages, but really you are just pulling up the item out of the database.

The Custom Object Model

At the base of it, we are making "Custom Objects" - so we need a model to interact with the database for these.  There are several methods that it will need.  Some are static, some are instance. Here's a list of all the methods:

  • static function getByID($id)
  • static function getByTitle($title)
  • public function delete()
  • public function save($data)
  • public static function add($data)
  • public function getTitle()
  • function getContent()
  • function getContentEditMode()
  • public function getCustomObjectID()
  • a whole bunch of functions about translating content

So every time that we want to add or update or delete one of our database rows, we'll be calling up this object and using one of these methods to do whatever we want to with it.  Here's what the whole model looks like.  I won't go into exactly what each function does, but they should be pretty self explanatory.

  1. <?php
  2.  
  3. defined('C5_EXECUTE') or die("Access Denied.");
  4.  
  5. class CustomObject extends Object {
  6.  
  7. static function getByID($id) {
  8. $db = Loader::db();
  9. $data = $db->getRow('SELECT * FROM customObjects WHERE coID = ?', array($id));
  10. if (!empty($data)) {
  11. $customObject = new CustomObject();
  12. $customObject->setPropertiesFromArray($data);
  13. }
  14. return (is_a($customObject, "CustomObject")) ? $customObject : false;
  15. }
  16.  
  17. static function getByTitle($title) {
  18. $db = Loader::db();
  19. $data = $db->getRow('SELECT * FROM customObjects WHERE title = ?', array($title));
  20. if (!empty($data)) {
  21. $customObject = new CustomObject();
  22. $customObject->setPropertiesFromArray($data);
  23. }
  24. return (is_a($customObject, "CustomObject")) ? $customObject : false;
  25. }
  26.  
  27. public function delete() {
  28. $db = Loader::db();
  29. $db->execute("DELETE FROM customObjects where coID = ?", array($this->getCustomObjectID()));
  30. }
  31.  
  32. public function save($data) {
  33. $db = Loader::db();
  34. $content = CustomObject::translateTo($data['content']);
  35. $title = $data['title'];
  36. $vals = array($title, $content, $this->getCustomObjectID());
  37. $db->query("UPDATE customObjects SET title = ?, content = ? WHERE coID = ?", $vals);
  38. $customObject = CustomObject::getByID($this->getCustomObjectID());
  39. return (is_a($customObject, "CustomObject")) ? $customObject : false;
  40. }
  41.  
  42. public static function add($data) {
  43. $db = Loader::db();
  44. $content = CustomObject::translateTo($data['content']);
  45. $title = $data['title'];
  46. $vals = array($title, $content);
  47. $db->query("INSERT INTO customObjects (title, content) VALUES (?, ?)", $vals);
  48. $id = $db->_insertID();
  49. if (intval($id) > 0) {
  50. return CustomObject::getByID($id);
  51. } else {
  52. return false;
  53. }
  54. }
  55.  
  56. public function getTitle() {
  57. return $this->title;
  58. }
  59.  
  60. function getContent() {
  61. $content = $this->translateFrom($this->content);
  62. return $content;
  63. }
  64.  
  65. public function getCustomObjectID() {
  66. return intval($this->coID);
  67. }
  68.  
  69. function br2nl($str) {
  70. $str = str_replace("\r\n", "\n", $str);
  71. $str = str_replace("<br />\n", "\n", $str);
  72. return $str;
  73. }
  74.  
  75. function getContentEditMode() {
  76. $content = $this->translateFromEditMode($this->content);
  77. return $content;
  78. }
  79.  
  80. public static function replacePagePlaceHolderOnImport($match) {
  81. $cPath = $match[1];
  82. if ($cPath) {
  83. $pc = Page::getByPath($cPath);
  84. return '{CCM:CID_' . $pc->getCollectionID() . '}';
  85. } else {
  86. return '{CCM:CID_1}';
  87. }
  88. }
  89.  
  90. public static function replaceDefineOnImport($match) {
  91. $define = $match[1];
  92. if (defined($define)) {
  93. return $r[$define];
  94. }
  95. }
  96.  
  97. public static function replaceImagePlaceHolderOnImport($match) {
  98. $filename = $match[1];
  99. $db = Loader::db();
  100. $fID = $db->GetOne('select fintval(ID from FileVersions where filename = ?', array($filename));
  101. return '{CCM:FID_' . $fID . '}';
  102. }
  103.  
  104. public static function replaceFilePlaceHolderOnImport($match) {
  105. $filename = $match[1];
  106. $db = Loader::db();
  107. $fID = $db->GetOne('select fID from FileVersions where filename = ?', array($filename));
  108. return '{CCM:FID_DL_' . $fID . '}';
  109. }
  110.  
  111. function translateFromEditMode($text) {
  112. // now we add in support for the links
  113.  
  114. $text = preg_replace(
  115. '/{CCM:CID_([0-9]+)}/i', BASE_URL . DIR_REL . '/' . DISPATCHER_FILENAME . '?cID=\\1', $text);
  116.  
  117. // now we add in support for the files
  118.  
  119. '/{CCM:FID_([0-9]+)}/i', array('CustomObject', 'replaceFileIDInEditMode'), $text);
  120.  
  121.  
  122. '/{CCM:FID_DL_([0-9]+)}/i', array('CustomObject', 'replaceDownloadFileIDInEditMode'), $text);
  123.  
  124.  
  125. return $text;
  126. }
  127.  
  128. function translateFrom($text) {
  129. // old stuff. Can remove in a later version.
  130. $text = str_replace('href="{[CCM:BASE_URL]}', 'href="' . BASE_URL . DIR_REL, $text);
  131. $text = str_replace('src="{[CCM:REL_DIR_FILES_UPLOADED]}', 'src="' . BASE_URL . REL_DIR_FILES_UPLOADED, $text);
  132.  
  133. // we have the second one below with the backslash due to a screwup in the
  134. // 5.1 release. Can remove in a later version.
  135.  
  136. $text = preg_replace(
  137. '/{\[CCM:BASE_URL\]}/i',
  138. '/{CCM:BASE_URL}/i'), array(
  139. BASE_URL . DIR_REL,
  140. BASE_URL . DIR_REL)
  141. , $text);
  142.  
  143. // now we add in support for the links
  144.  
  145. '/{CCM:CID_([0-9]+)}/i', array('CustomObject', 'replaceCollectionID'), $text);
  146.  
  147. '/<img [^>]*src\s*=\s*"{CCM:FID_([0-9]+)}"[^>]*>/i', array('CustomObject', 'replaceImageID'), $text);
  148.  
  149. // now we add in support for the files that we view inline
  150. '/{CCM:FID_([0-9]+)}/i', array('CustomObject', 'replaceFileID'), $text);
  151.  
  152. // now files we download
  153.  
  154. '/{CCM:FID_DL_([0-9]+)}/i', array('CustomObject', 'replaceDownloadFileID'), $text);
  155.  
  156. return $text;
  157. }
  158.  
  159. private function replaceFileID($match) {
  160. $fID = $match[1];
  161. if ($fID > 0) {
  162. $path = File::getRelativePathFromID($fID);
  163. return $path;
  164. }
  165. }
  166.  
  167. private function replaceImageID($match) {
  168. $fID = $match[1];
  169. if ($fID > 0) {
  170. preg_match('/width\s*="([0-9]+)"/', $match[0], $matchWidth);
  171. preg_match('/height\s*="([0-9]+)"/', $match[0], $matchHeight);
  172. $file = File::getByID($fID);
  173. if (is_object($file) && (!$file->isError())) {
  174. $imgHelper = Loader::helper('image');
  175. $maxWidth = ($matchWidth[1]) ? $matchWidth[1] : $file->getAttribute('width');
  176. $maxHeight = ($matchHeight[1]) ? $matchHeight[1] : $file->getAttribute('height');
  177. if ($file->getAttribute('width') > $maxWidth || $file->getAttribute('height') > $maxHeight) {
  178. $thumb = $imgHelper->getThumbnail($file, $maxWidth, $maxHeight);
  179. return preg_replace('/{CCM:FID_([0-9]+)}/i', $thumb->src, $match[0]);
  180. }
  181. }
  182. return $match[0];
  183. }
  184. }
  185.  
  186. private function replaceDownloadFileID($match) {
  187. $fID = $match[1];
  188. if ($fID > 0) {
  189. $c = Page::getCurrentPage();
  190. if (is_object($c)) {
  191. return View::url('/download_file', 'view', $fID, $c->getCollectionID());
  192. } else {
  193. return View::url('/download_file', 'view', $fID);
  194. }
  195. }
  196. }
  197.  
  198. private function replaceDownloadFileIDInEditMode($match) {
  199. $fID = $match[1];
  200. if ($fID > 0) {
  201. return View::url('/download_file', 'view', $fID);
  202. }
  203. }
  204.  
  205. private function replaceFileIDInEditMode($match) {
  206. $fID = $match[1];
  207. return View::url('/download_file', 'view_inline', $fID);
  208. }
  209.  
  210. private function replaceCollectionID($match) {
  211. $cID = $match[1];
  212. if ($cID > 0) {
  213. $c = Page::getByID($cID, 'APPROVED');
  214. return Loader::helper("navigation")->getLinkToCollection($c);
  215. }
  216. }
  217.  
  218. public static function translateTo($text) {
  219. // keep links valid
  220. $url1 = str_replace('/', '\/', BASE_URL . DIR_REL . '/' . DISPATCHER_FILENAME);
  221. $url2 = str_replace('/', '\/', BASE_URL . DIR_REL);
  222. $url3 = View::url('/download_file', 'view_inline');
  223. $url3 = str_replace('/', '\/', $url3);
  224. $url3 = str_replace('-', '\-', $url3);
  225. $url4 = View::url('/download_file', 'view');
  226. $url4 = str_replace('/', '\/', $url4);
  227. $url4 = str_replace('-', '\-', $url4);
  228. $text = preg_replace(
  229. '/' . $url1 . '\?cID=([0-9]+)/i',
  230. '/' . $url3 . '([0-9]+)\//i',
  231. '/' . $url4 . '([0-9]+)\//i',
  232. '/' . $url2 . '/i'), array(
  233. '{CCM:CID_\\1}',
  234. '{CCM:FID_\\1}',
  235. '{CCM:FID_DL_\\1}',
  236. '{CCM:BASE_URL}')
  237. , $text);
  238. return $text;
  239. }
  240.  
  241. }

The Custom Object List

The next thing that forms the core of our application is the Custom Object List.  This extends the concrete5 built in class DatabaseItemList, which gives you a lot of really great features.  It's kind of hard to explain everything that you can do with this class, but I'm going to try.  Basically it's a system for querying the database, and presenting it back as a paginated array.  There are methods for sorting and filtering the list, complete with methods to make links that will sort and call for the sort selected class to style your sortable search column headers.  You can make the database queries as complex as you need them to be, in this case I'm keeping it really simple to show the very basics.

Base Paramaters

We start out with the base values for 3 parameters.

  1. private $queryCreated;
  2. protected $autoSortColumns = array("title");
  3. protected $itemsPerPage = 10;

The thing that really matters here is the $autoSortColumns - if you don't define your sort columns here then sorting on the front end won't work at all.  If you are using an alias in your base query, then this will be the name of the alias.  I think there might be ways of setting this but I'm not sure what they are.  If you look at the way that search results work in Page Search or User Search in the dashboard, you can add extra search fields and extra results columns with a few clicks.  I'm not getting into how to do that, here I'm sticking with a hard coded results list and search form.  But if you want to do something more complicated like those pages, look at the files in the core that correspond with the ones in this sample package and hopefully it makes sense to you how to expand things.

Setting the Base Query

The next thing we want to look at is setting our base query.  This is pretty key to what you get back in your search results.  For this application, it's pretty simple:

  1. protected function setBaseQuery() {
  2. $this->setQuery('SELECT * FROM customObjects');
  3. }
  4.  
  5. public function createQuery() {
  6. if (!$this->queryCreated) {
  7. $this->setBaseQuery();
  8. $this->queryCreated = 1;
  9. }
  10. }

This can get a lot more complicated.   Here's an example from another package I've been working on:

  1. protected function setBaseQuery($extra = "") {
  2. $this->setQuery("SELECT
  3. cmCommunities.cmID,
  4. csia.ak_cm_community_name AS community_name,
  5. u.uID AS uID,
  6. u.uName AS user_name,
  7. csia.ak_cm_community_state AS state,
  8. csia.ak_cm_community_zip_code AS zip_code,
  9. csia.ak_cm_amenity_titles,
  10. csia.ak_cm_living_option_titles"
  11. . $extra .
  12. " FROM cmCommunities
  13. LEFT JOIN Collections c ON cmCommunities.cID = c.cID
  14. LEFT JOIN CollectionVersions cv ON cv.cID = c.cID and cvIsApproved = 1
  15. LEFT JOIN CollectionSearchIndexAttributes csia ON cmCommunities.cID = csia.cID
  16. LEFT JOIN Users AS u ON cmCommunities.uID = u.uID
  17. LEFT JOIN UserSearchIndexAttributes AS usia ON u.uID = usia.uID
  18. LEFT JOIN cmCommunitiesLivingOptionsPages AS clop ON cmCommunities.cmID = clop.cmID
  19. LEFT JOIN cmCommunitiesAmenitiesPages AS cap ON cmCommunities.cmID = cap.cmID");
  20. }
  21.  
  22. public function createQuery() {
  23. if (!$this->queryCreated) {
  24. if (strlen($this->extra)>0){
  25. $this->setBaseQuery($this->extra);
  26. } else {
  27. $this->setBaseQuery();
  28. }
  29. $this->queryCreated = 1;
  30. }
  31. }

In the second one, I have a form for creating my objects that's a lot more complicated.  That form has options for what page type to add, what parent page to add the page under, and what user to associate with the page.  This is all tied through the secondary table.  Instead of having to pull up a whole page list with all the complicated functions, we can store the cID of the page in our custom object table and then join with that to the CollectionSearchIndexAttributes table and use that to filter by attributes even though we don't have attributes associated with our custom object.  You can also join to the UserSearchIndexAttributes table to get attribues from there, or the FileSearchIndexAttributes table. 

The point here is that you can set up your base query to be as complicated as you actually need it to be.  Creating your filters becomes a little more compicated.  The main difference when you are joining across many tables is that you need to preface your filter calls with the alias of the table you want to filter on.  Just using the aliased name of the column won't work.  In the autosort list and front-end search functions, the aliased name is what you want to use. But in filter, you must use the full table alias and column name.  I'm not sure why that is.  It makes some stuff not really work, I tried to set up some base queries that used CONCAT() in the SQL with an alias, then wanted to filter on that alias and it didnt't work. 

Getting Results

In order to really make our list useful, we want to override the get method of the parent.  If we didn't, we would still get an array of database rows that match our criteria, but we wouldn't have a list of actual objects that we want to interact with.  So we will be calling the parent get function to get our database row / array, then looping over that to create our actual Custom Objects and return that instead.  You could decide to do this differently - maybe you want to return just an array and then use ids in that array to create your objects.  I think that creating objects and returning them is simpler and cleaner.  If you need to get page or user objects from your custom object, it's a simple matter to create a getPageObject() or a getUserObject() method to pull in the actual object you need.  I guess that's one of the nice things about concrete5 - you could do it however you want to.

Here's my get method:

  1. public function get($itemsToGet = 0, $offset = 0) {
  2. Loader::model("custom_object", "custom_objects_demo");
  3. $customObjectsList = array();
  4. $this->createQuery();
  5. $r = parent::get($itemsToGet, $offset);
  6. foreach ($r as $row) {
  7. $customObject = CustomObject::getByID($row['coID']);
  8. $customObjectsList[] = $customObject;
  9. }
  10. return $customObjectsList;
  11. }
  12.  
  13. public function getTotal() {
  14. $this->createQuery();
  15. return parent::getTotal();
  16. }

Getting All The Objects

For the application we're building, we have one point where we need to get all the objects for the front end list.  Rather than just resetting any query and calling get with no parameters, we're making a static method to get it a bit cleaner and easier.

  1. public static function getAllCustomObjects() {
  2. Loader::model("custom_object", "custom_objects_demo");
  3. $customObjectList = array();
  4. $customObjectList = new CustomObjectList();
  5. $customObjectList->createQuery();
  6. $customObjects = $customObjectList->get();
  7. return $customObjects;
  8. }

Filtering Results

There's a whole lot that you can do with the filter function, actually.  Basically, it allows you to add to the WHERE statements in your sql query.  In the item list, this is what the input looks like public function filter($column, $value, $comparison = '=')

If you pass in false as the first parameter, then it's just anything want for the $value of the filter.  Leaving the comparison empty means that it won't be used.  So unless you are actually using the $column value, you can just pass in false, then your filter and be good.  Here are a coupe of examples of filtering:

  1. public function filterByTitle($title){
  2. $db = Loader::db();
  3. $title = $db->quote("%" . $title . "%");
  4. $this->filter(false, '(title LIKE ' . $title . ')');
  5. }
  6.  
  7. public function filterByCommunityName($name) {
  8. $db = Loader::db();
  9. $name = $db->quote("%" . $name . "%");
  10. $this->filter(false, '(csia.ak_cm_community_name LIKE ' . $name . ')');
  11. }

There's probably more that could be said about the DatabaseItemList - it's really the core of a whole lot of how concrete5 does things. Objects, and lists of objects. 

Anyway, here's what our whole custom object list looks like:

  1. <?php
  2.  
  3. defined('C5_EXECUTE') or die("Access Denied.");
  4.  
  5. /**
  6.  *
  7.  * A filtered list of custom objects
  8.  * @package Item List Demo
  9.  *
  10.  */
  11. class CustomObjectList extends DatabaseItemList {
  12.  
  13. private $queryCreated;
  14. protected $autoSortColumns = array("title");
  15. protected $itemsPerPage = 10;
  16.  
  17. protected function setBaseQuery() {
  18. $this->setQuery('SELECT * FROM customObjects');
  19. }
  20.  
  21. public function createQuery() {
  22. if (!$this->queryCreated) {
  23. $this->setBaseQuery();
  24. $this->queryCreated = 1;
  25. }
  26. }
  27.  
  28. public function get($itemsToGet = 0, $offset = 0) {
  29. Loader::model("custom_object", "custom_objects_demo");
  30. $customObjectsList = array();
  31. $this->createQuery();
  32. $r = parent::get($itemsToGet, $offset);
  33. foreach ($r as $row) {
  34. $customObject = CustomObject::getByID($row['coID']);
  35. $customObjectsList[] = $customObject;
  36. }
  37. return $customObjectsList;
  38. }
  39.  
  40. public function getTotal() {
  41. $this->createQuery();
  42. return parent::getTotal();
  43. }
  44.  
  45. public static function getAllCustomObjects() {
  46. Loader::model("custom_object", "custom_objects_demo");
  47. $customObjectList = array();
  48. $customObjectList = new CustomObjectList();
  49. $customObjectList->createQuery();
  50. $customObjects = $customObjectList->get();
  51. return $customObjects;
  52. }
  53.  
  54. public function filterByTitle($title) {
  55. $db = Loader::db();
  56. $title = $db->quote("%" . $title . "%");
  57. $this->filter(false, "(title LIKE " . $title . ")");
  58. }
  59.  
  60. }

Dashboard Pages

The dashboard I've set up I think is pretty solid, but might be better set up.  What I do is make a search / results page, and then an add / edit page.  It may be better to have a separate page for each.  I didn't like some of how it makes the URLs in the dashboard semantically.  I guess that's really a kind of stupid thing to think about, but I do.  I also like keeping code all in one place, so it's kind of OK.

Redirect Page

First off, we have to create our controller for the main 'view' for the custom objects page.  In 5.5 this link is clickable, in 5.6 it's not.  Either way, we want it to just redirect to the search page.  This is what that looks like:

  1. defined('C5_EXECUTE') or die("Access Denied.");
  2. class DashboardCustomObjectsController extends Controller {
  3. public function __construct() {
  4. $this->redirect('/dashboard/custom_objects/search');
  5. }
  6. }

Search Page

The search page shows lists of custom objects, then the results of saving / editing custom objects on the 'add' page.   By setting up our views and wrapper IDs and class names you can get a fully functional ajax enabled search interface that's managed by the concrete5 core, with very little need for you to code functionality.  You will still need to program some stuff, but a lot of the core functionality is basically built in.

As with most concrete5 single pages, it's just a controller and a view, calling on our Custom Object List and Custom Object to get it's data.   The main thing here is looking at the view function.  There's one line there that makes everything work:

  1. <?php
  2.  
  3. defined('C5_EXECUTE') or die("Access Denied.");
  4.  
  5. class DashboardCustomObjectsSearchController extends Controller {
  6.  
  7. public function on_start() {
  8.  
  9. Loader::model('custom_object', 'custom_objects_demo');
  10. Loader::model('custom_object_list', 'custom_objects_demo');
  11. $this->set('form', Loader::helper('form'));
  12. $this->set('valt', Loader::helper('validation/token'));
  13. $this->set('valc', Loader::helper('concrete/validation'));
  14. $this->set('ih', Loader::helper('concrete/interface'));
  15. $this->set('av', Loader::helper('concrete/avatar'));
  16. $this->set('dtt', Loader::helper('form/date_time'));
  17.  
  18. $this->error = Loader::helper('validation/error');
  19. }
  20.  
  21. public function view() {
  22. $html = Loader::helper('html');
  23. $form = Loader::helper('form');
  24. $this->set('form', $form);
  25. $this->addHeaderItem('<script type="text/javascript">$(function() { ccm_setupAdvancedSearch(\'custom-objects\'); });</script>');
  26. $customObjectsList = $this->getRequestedSearchResults();
  27. $customObjects = $customObjectsList->getPage();
  28.  
  29. $this->set('customObjectsList', $customObjectsList);
  30. $this->set('customObjects', $customObjects);
  31.  
  32. if ($_REQUEST['custom_object_created']) {
  33. $this->set('message', t('Custom Object Created.'));
  34. $customObject = CustomObject::getByID($_REQUEST['coID']);
  35. $this->set('customObject', $customObject);
  36. }
  37.  
  38. if ($_REQUEST['custom_object_updated']) {
  39. $this->set('message', t('Custom Object Updated.'));
  40. $customObject = CustomObject::getByID($_REQUEST['coID']);
  41. $this->set('customObject', $customObject);
  42. }
  43.  
  44. if ($_REQUEST['custom_object_deleted']) {
  45. $this->set('message', t('Custom Object Deleted.'));
  46. }
  47. }
  48.  
  49. public function getRequestedSearchResults() {
  50.  
  51. Loader::model('custom_object', 'custom_objects_demo');
  52. Loader::model('custom_object_list', 'custom_objects_demo');
  53.  
  54. $customObjectsList = new CustomObjectList();
  55.  
  56. if ($_REQUEST['title'] != '') {
  57. $customObjectsList->filterByTitle($_GET['title']);
  58. }
  59. if ($_REQUEST['numResults']) {
  60. $customObjectsList->setItemsPerPage($_REQUEST['numResults']);
  61. }
  62. return $customObjectsList;
  63. }
  64.  
  65. }

The addHeaderItem with the ccm_setupAdvancedSearch call is what makes the whole search interface work.  The value passed into the function is used to determine what html DOM elements are used in the search. On the front end, it's a div with a class of ccm-pane-options that either holds the buttons when displaying results from a Custom Object update, or the search form when you are in list view.  Then an area that gets switched out below in search view is swapped out via ajax from the form fields in the search form with the contents of the "/elements/custom_objects/search_results.php" when you submit the form.

The other part of what makes this work is the function getRequestedSearchResults() on the controller.  This can be called remotely, which means that you can get a list from the page based on whatever request parameters you send via ajax.  You call it from inside of the tools file for the custom object, "/tools/custom_objects/search_results.php".

  1. $cnt = Loader::controller('/dashboard/custom_objects/search');
  2. $customObjectsList = $cnt->getRequestedSearchResults();
  3.  
  4. $customObjects = $customObjectsList->getPage();
  5.  
  6. Loader::packageElement('custom_objects/search_results', 'custom_objects_demo', array('customObjects' => $customObjects, 'customObjectsList' => $customObjectsList));

Search Results Element

The dashboard search results are pulled in from a package element, both in the initial view and then when loaded in from the built in search interface.  So both need the same root element to allow the system to hook onto it and swap it out, and that root element has to match what you put in the addHeaderItem() call.  So if it's 'custom-objects' there, then your ID would be ccm-custom-objects-search-results - if it was some other object that you wanted to initiate, it would be ccm-some-other-object-search-results for the ID that wraps your element.

Here's what the dashboard search results element looks like:

  1. <?php
  2. defined('C5_EXECUTE') or die("Access Denied.");
  3. $ih = Loader::helper('concrete/interface');
  4. $form = Loader::helper('form');
  5. ?>
  6.  
  7. <div id="ccm-custom-objects-search-results">
  8.  
  9. <div class="ccm-pane-body">
  10. <div style="margin-bottom: 10px">
  11. <?php print $ih->button(t('Add Custom Object'), View::url('/dashboard/custom_objects/add'), 'right', 'primary'); ?>
  12. <div class="clearfix"></div>
  13. </div>
  14. <div id="ccm-list-wrapper">
  15. <a name="ccm-custom-object-list-wrapper-anchor"></a>
  16. <?php
  17. $txt = Loader::helper('text');
  18. $title = $_REQUEST['title'];
  19. $url = Loader::helper('concrete/urls');
  20. $bu = $url->getToolsURL('custom_objects/search_results', 'custom_objects_demo');
  21.  
  22. if (count($customObjects) > 0) {
  23. ?>
  24. <table border="0" cellspacing="0" cellpadding="0" id="ccm-custom-object-list" class="ccm-results-list">
  25. <tr>
  26. <th class="<?php echo $customObjectsList->getSearchResultsClass('title') ?>">
  27. <a href="<?php echo $customObjectsList->getSortByURL('title', 'asc', $bu) ?>">
  28. <?php echo t("Title"); ?>
  29. </a>
  30. </th>
  31. <th width="135px"></th>
  32. </tr>
  33. <?php
  34. foreach ($customObjects as $customObject) {
  35. $editAction = View::url('/dashboard/custom_objects/add', 'edit', $customObject->getCustomObjectID());
  36. $deleteAction = View::url('/dashboard/custom_objects/add', 'confirm_delete', $customObject->getCustomObjectID());
  37.  
  38. if (!isset($striped) || $striped == 'ccm-list-record-alt') {
  39. $striped = '';
  40. } else if ($striped == '') {
  41. $striped = 'ccm-list-record-alt';
  42. }
  43. ?>
  44.  
  45. <tr class="ccm-list-record <?php echo $striped ?>">
  46. <td><?php echo $txt->highlightSearch($customObject->getTitle(), $title); ?></td>
  47. <td>
  48. <?php print $ih->button(t('Edit'), $editAction, 'right', 'primary', array('style' => "margin-left: 10px")); ?>
  49. <?php print $ih->button(t('Delete'), $deleteAction, 'right', 'error'); ?>
  50. </td>
  51. </tr>
  52. <?php
  53. }
  54. ?>
  55. </table>
  56. <?php } else { ?>
  57. <div id="ccm-list-none"><?php echo t('No Custom Objects Found.') ?></div>
  58. <?php } ?>
  59.  
  60. </div>
  61.  
  62. <?php $customObjectsList->displaySummary(); ?>
  63. <div class="clearfix"></div>
  64. </div>
  65.  
  66. <div class="ccm-pane-footer">
  67. <?php $customObjectsList->displayPagingV2($bu, false); ?>
  68. <div class="clearfix"></div>
  69. </div>
  70.  
  71. </div>

First off we're making a link / button to add a new Custom Object, and displaying that above our list.  The list is a simple table.  We grab some of the request variables and get the link back to our search results tools file for highlighting the search results and making our title column sortable by clicking on it.  The DatabaseItemList that we are extending has two custom methods in it, one is getSearchResultsClass('column') and the other is getSortByURL('column', 'order', "base_url") - these will only work if you've set the column as sortable in the CustomObjectsList controller.  The text helper has a method for highlightSearch - pass in the value you want to highlight $customObject->getTitle() and then the value you want to check for which we're grabbing from the $_REQUEST array.  The last part of our search results list are links to the Add page to edit or delete each individual object.

The Search Form

I'm keeping this pretty simple, but you could get as complicated as you want with this.  The form submits to our tools file, so we use the urls helper to get the url we're going to submit to.  I don't know if it makes a difference to use post or get, but all the samples I've seen use get so I kept with that.  We have a title text field, and a drop down for the number of results.  The image underneath our submit button has our custom-objects in the id - ccm-custom-objects-search-loading.  This means that the built in search javascript will show it as the request is being submitted. 

  1. <?php defined('C5_EXECUTE') or die("Access Denied."); ?>
  2. <?php $form = Loader::helper('form');
  3. $url = Loader::helper('concrete/urls');
  4. $urlSearchAction = $url->getToolsURL('custom_objects/search_results', 'custom_objects_demo');
  5. ?>
  6.  
  7. <form method="get" id="ccm-custom-objects-advanced-search" action="<?php echo $urlSearchAction;?>">
  8. <input type="hidden" name="search" value="1" />
  9.  
  10. <div class="ccm-pane-options-permanent-search">
  11.  
  12. <div style="width: 160px; margin-left: 20px; float: left;">
  13. <?php echo $form->label('title', t('Title'))?>
  14. <?php echo $form->text('title', $_REQUEST['title'], array('placeholder' => t('Title'), 'style'=> 'width: 140px')); ?>
  15. </div>
  16. <div style="width: 100px; margin-left: 20px; float: left;">
  17. <?php echo $form->label('numResults', t('# Per Page'))?>
  18. <?php echo $form->select('numResults', array(
  19. '10' => '10',
  20. '25' => '25',
  21. '50' => '50',
  22. '100' => '100',
  23. '500' => '500'
  24. ), $_REQUEST['numResults'], array('style' => 'width:65px'))?>
  25. </div>
  26. <div style="width: 100px; margin-left: 20px; float: left;">
  27. <?php echo $form->submit('ccm-search-custom-objects', t('Search'), array('style' => 'margin-left: 10px; margin-top: 20px;'))?>
  28. <img src="<?php echo ASSETS_URL_IMAGES?>/loader_intelligent_search.gif" width="43" height="11" class="ccm-search-loading" id="ccm-custom-objects-search-loading" />
  29. </div>
  30. </div>
  31. </form>
  32.  

Putting it all together into a page

The search page is also used when results need to displayed from saving a Custom Object.  Those pages will redirect here, and it will then show the details of the saved Object and some links to edit it again, delete it, or go back to list view.

Here's how the overall view shows these different things:

  1. <?php
  2. defined('C5_EXECUTE') or die("Access Denied.");
  3. $ih = Loader::helper('concrete/interface');
  4. ?>
  5.  
  6.  
  7. <?php if ($_REQUEST['custom_object_created'] || $_REQUEST['custom_object_updated']) { ?>
  8. <?php echo Loader::helper('concrete/dashboard')->getDashboardPaneHeaderWrapper(t('Custom Object Details'), "", false, false); ?>
  9. <div class="ccm-pane-options">
  10. <?php print $ih->button(t('Edit Again'), $this->url('/dashboard/custom_objects/add/', 'edit', $customObject->getCustomObjectID()), 'left', 'primary'); ?>
  11. <?php print $ih->button(t('Delete'), $this->url('/dashboard/custom_objects/add/', 'confirm_delete', $customObject->getCustomObjectID()), 'left', 'error'); ?>
  12. <?php print $ih->button(t('Back to List'), $this->url('/dashboard/custom_objects/search/'), 'right', 'primary'); ?>
  13. </div>
  14. <div class="ccm-pane-body">
  15. <dl>
  16. <dt><?php echo t("Title");?></dt>
  17. <dd><?php echo $customObject->getTitle();?></dd>
  18. <dt><?php echo t("Content");?></dt>
  19. <dd><?php echo $customObject->getContent();?></dd>
  20. </dl>
  21. </div>
  22. <?php } else { ?>
  23. <?php echo Loader::helper('concrete/dashboard')->getDashboardPaneHeaderWrapper(t('Search Custom Objects'), t('Search and Edit Custom Objects.'), false, false); ?>
  24. <div class="ccm-pane-options" id="ccm-custom-objects-pane-options">
  25. <?php
  26. Loader::packageElement('custom_objects/search_form', 'custom_objects_demo');
  27. ?>
  28. </div>
  29.  
  30. <?php
  31. Loader::packageElement(
  32. 'custom_objects/search_results', 'custom_objects_demo', array(
  33. 'customObjects' => $customObjects,
  34. 'customObjectsList' => $customObjectsList));
  35. ?>
  36. <?php } ?>
  37. <?php echo Loader::helper('concrete/dashboard')->getDashboardPaneFooterWrapper(false); ?>
  38.  

I could probably make this a bit cleaner, setting the custom_object_created and custom_object_updated in the controller for the view, if it was a front-end page I might do that.  For a dashboard page I don't think it matters as much.  At any rate, if either of those are set, then we should have a Custom Object to show the details of.  We're showing this 'success' page because we want to show the success message above the dashboard panel.  This message doesn't go away after a bit of time, so instead of having it there for a long time while you search and sort the objects we show the details of the object, with links to delete or edit it, and then a link back to the search list.

If those aren't set, then we're displaying the search form and results elements.  Because of our header javascript, the search form will be submitted via ajax to our tools file, and the results will replace the outer div.  The naming is pretty important here.  For the search form, it needs to be an id of "ccm-your-object-advanced-search" and for the results it's "ccm-your-object-search-results" where "your-object" is the parameter you passed into the header javascript. ccm_setupAdvancedSearch('your-object');

The Add / Edit Page

We're kind of combining both functions here, you might want to split things out a bit and have different dashboard pages for each.  It's really up to you, I think.  There are several methods on the controller that edit or delete objects.  Adding is assumed if no other method is being called.  If there are post variables for 'create' or 'update' in the view then we're validating the form, setting errors, or if it's good we create or update our CustomObject and redirect to the dashboard search page and display our success message.

It's worth noting the difference in how update and add work.  In the create section, we're calling a static function CustomObject::add($data) which will return either the custom object or false.  In the update section, we're again calling a static function, CustomObject::getByID($coID) which will return the same way.  If we have a valid Custom Object, we then call the instance method $customObject->save($data).  For the save method it also returns the same way, with the updated object. It probably should be verifying that it's a valid object returned, but it just redirects.

The delete function is set up in two different functions.  The first one is 'confirm_delete' which will get our Custom Object and assign it to the view, which then links back to the actual 'delete' function on the controller.  By using the validation/token helper we can assure that our requests are coming from the confirm_delete view, and keep people from just hitting /dashboard/custom_objects/add/delete/coID and deleting objects without validation or confirmation.

Here's the controller:

  1. <?php
  2.  
  3. defined('C5_EXECUTE') or die("Access Denied.");
  4.  
  5. class DashboardCustomObjectsAddController extends Controller {
  6.  
  7. public function on_start() {
  8. Loader::model('custom_object', 'custom_objects_demo');
  9. Loader::model('custom_object_list', 'custom_objects_demo');
  10. $this->set('form', Loader::helper('form'));
  11. $this->set('valt', Loader::helper('validation/token'));
  12. $this->set('valc', Loader::helper('concrete/validation'));
  13. $this->set('ih', Loader::helper('concrete/interface'));
  14.  
  15. $this->error = Loader::helper('validation/error');
  16. }
  17.  
  18. public function view() {
  19.  
  20. $vals = Loader::helper('validation/strings');
  21. $valt = Loader::helper('validation/token');
  22. $valc = Loader::helper('concrete/validation');
  23.  
  24. if ($_POST['create']) {
  25.  
  26. $title = $_POST['title'];
  27. if (!$vals->notempty($title)) {
  28. $this->error->add(t('Please include a title.'));
  29. }
  30.  
  31. $content = $_POST['content'];
  32. if (!$vals->notempty($content)) {
  33. $this->error->add(t('Please include content.'));
  34. }
  35.  
  36. if (!$valt->validate('create_custom_object')) {
  37. $this->error->add($valt->getErrorMessage());
  38. }
  39.  
  40. if (!$this->error->has()) {
  41. $data = array(
  42. 'title' => $title,
  43. 'content' => $content);
  44. $customObject = CustomObject::add($data);
  45. if (is_a($customObject, "CustomObject")) {
  46. $this->redirect('/dashboard/custom_objects/search?coID=' . $customObject->getCustomObjectID() . '&custom_object_created=1');
  47. } else {
  48. $this->error->add(t('An error occurred while trying to create this Custom Object.'));
  49. $this->set('error', $this->error);
  50. }
  51. } else {
  52. $this->set('error', $this->error);
  53. }
  54. }
  55.  
  56. if ($_POST['update']) {
  57.  
  58. $coID = $_POST['coID'];
  59. if (!intval($coID) > 0) {
  60. $this->error->add(t('Invalid Custom Object ID.'));
  61. }
  62.  
  63. $title = $_POST['title'];
  64. if (!$vals->notempty($title)) {
  65. $this->error->add(t('Please include a title.'));
  66. }
  67.  
  68. $content = $_POST['content'];
  69. if (!$vals->notempty($content)) {
  70. $this->error->add(t('Please include content.'));
  71. }
  72.  
  73. if (!$valt->validate('update_custom_object')) {
  74. $this->error->add($valt->getErrorMessage());
  75. }
  76.  
  77. if (!$this->error->has()) {
  78. $customObject = CustomObject::getByID($coID);
  79. $data = array(
  80. 'title' => $title,
  81. 'content' => $content);
  82. if (is_a($customObject, "CustomObject")) {
  83. $customObject->save($data);
  84. $this->redirect('/dashboard/custom_objects/search?coID=' . $customObject->getCustomObjectID() . '&custom_object_updated=1');
  85. } else {
  86. $this->error->add(t('An error occurred while trying to update this Custom Object.'));
  87. $this->set('error', $this->error);
  88. }
  89. } else {
  90. $this->set('error', $this->error);
  91. }
  92. }
  93. }
  94.  
  95. public function edit($coID) {
  96. $customObject = CustomObject::getByID($coID);
  97. if (is_a($customObject, "CustomObject")) {
  98. $this->set("customObject", $customObject);
  99. $this->view();
  100. } else {
  101. $this->error->add(t("Invalid Custom Object ID"));
  102. }
  103. }
  104.  
  105. public function delete($coID) {
  106. $valt = Loader::helper('validation/token');
  107. if (!$valt->validate('delete_custom_object')) {
  108. $this->error->add($valt->getErrorMessage());
  109. }
  110.  
  111. if (!$this->error->has()) {
  112.  
  113. $customObject = CustomObject::getByID($coID);
  114.  
  115. if (is_a($customObject, "CustomObject")) {
  116. $customObject->delete();
  117. $this->redirect('/dashboard/custom_objects/search?custom_object_deleted=1');
  118. }
  119. } else {
  120. $customObject = CustomObject::getByID($coID);
  121. if (is_a($customObject, "Custom Object")) {
  122. $this->set("customObject", $customObject);
  123. $this->set("delete", 1);
  124. $this->view();
  125. } else {
  126. $this->error->add(t("Invalid Custom Object ID"));
  127. }
  128. $this->set('error', $this->error);
  129. }
  130. }
  131.  
  132. public function confirm_delete($coID) {
  133. $customObject = CustomObject::getByID($coID);
  134. if (is_a($customObject, "CustomObject")) {
  135. $this->set("customObject", $customObject);
  136. $this->set("delete", 1);
  137. $this->view();
  138. } else {
  139. $this->error->add(t("Invalid Custom Object ID"));
  140. $this->view();
  141. }
  142. }
  143.  
  144. }
  145.  

The view here is a bit convoluted.  Since it's being used for adding / editing and deleting, we have to change the output quite a bit based on the task being done.  We also have to get our data from two different sources - if we are editing and it's our first time displaying the page, then we want to pull it in from the CustomObject that the controller passed in.  But if we've submitted the form and there were errors, then we want to be grabbing from the $_POST array so that we have the most recent data and not the old data from the object we're editing.  So we set a variable $useCustomObjectDetails when showing the edit form to change where we get the information from.  There are a couple of hidden fields at the end of the edit / add form that help keep track of what we are doing, and the validation token changes according to it being an update or an addition.

I'm sure there are probably better ways to do this, but it seems pretty solid to me and I haven't had any real problems with it, so I'll keep doing it like this.  For more complex forms this can get kind of overwhelming, but it does scale well. 

One thing worth noting is that the form is using the full tinyMCE WYSIWYG (what you see is what you get) editor.  This is pretty easy to include in concrete5.  There's an element that you can include from the core to to create one.  In our package we're using a package element instead of the built in one.  The package element is based off the include files from the Content block instead.  That one checks the current page for a theme and then uses the typography.css file from that theme to style the content in the editor.  Since we are in the dashboard, we are just grabbing the home page and getting the theme page from it.  Simply loading the package element editor_init and giving our text area the class ccm-advanced-editor will turn it into a rich text editor.  I'm not going to include the elements in this page, but you can check out the files in the attached package if you're interested.

Here's the view:

  1.  
  2. <?php
  3. defined('C5_EXECUTE') or die("Access Denied.");
  4.  
  5. $th = Loader::helper('text');
  6.  
  7. if (is_a($customObject, "CustomObject") && intval($_POST['create']) == 0) {
  8. $useCustomObjectDetails = 1;
  9. $isUpdate = 1;
  10. }
  11. if (intval($_POST['isUpdate']) > 0) {
  12. $isUpdate = 1;
  13. }
  14. if (is_a($customObject, "CustomObject") && $delete) {
  15. $confirmDelete = 1;
  16. }
  17. ?>
  18. <?php
  19. if ($confirmDelete) {
  20. ?>
  21. <?php echo Loader::helper('concrete/dashboard')->getDashboardPaneHeaderWrapper(t('Delete Custom Object'), false, false, false); ?>
  22. <form method="post" enctype="multipart/form-data" id="cm-delete-custom-object-form" action="<?php echo $this->url('/dashboard/custom_objects/add', 'delete', $customObject->getCustomObjectID()) ?>">
  23. <?php echo $valt->output('delete_custom_object'); ?>
  24. <div class="ccm-pane-body">
  25. <h2><?php echo t("Do you really want to delete this Custom Object?"); ?></h2>
  26. <dl>
  27. <dt><?php echo t("Title"); ?></dt>
  28. <dd><?php echo $customObject->getTitle(); ?></dd>
  29. <dt><?php echo t("Content"); ?></dt>
  30. <dd><?php echo $customObject->getContent(); ?></dd>
  31. </dl>
  32. </div>
  33. <div class="ccm-pane-footer">
  34. <div class="ccm-buttons">
  35. <input type="hidden" name="do_delete" value="1" />
  36. <input type="hidden" name="coID" value="<?php echo $customObject->getCustomObjectID(); ?>" />
  37. <?php print $ih->button(t('Cancel'), $this->url('/dashboard/custom_objects/search'), 'left', 'error') ?>
  38. <?php print $ih->submit(t('Delete Permanently'), 'ccm-user-form', 'right', 'primary'); ?>
  39. </div>
  40. </div>
  41. <?php } else {
  42. ?>
  43. <?php if ($isUpdate) { ?>
  44. <?php echo Loader::helper('concrete/dashboard')->getDashboardPaneHeaderWrapper(t('Edit Custom Object'), false, false, false); ?>
  45. <?php } else { ?>
  46. <?php echo Loader::helper('concrete/dashboard')->getDashboardPaneHeaderWrapper(t('Add Custom Object'), false, false, false); ?>
  47. <?php } ?>
  48. <form method="post" enctype="multipart/form-data" id="cm-custom-object-form" action="<?php echo $this->url('/dashboard/custom_objects/add') ?>">
  49. <div class="ccm-pane-body">
  50. <?php
  51. if ($useCustomObjectsDetails || $isUpdate) {
  52. echo $valt->output('update_custom_object');
  53. ?>
  54. <?php } else {
  55. echo $valt->output('create_custom_object');
  56. ?>
  57. <?php } ?>
  58.  
  59. <table border="0" cellspacing="0" cellpadding="0" width="100%" class="ccm-grid">
  60. <thead>
  61. <tr>
  62. <th><?php echo t('Custom Object Information') ?></th>
  63. </tr>
  64. </thead>
  65. <tbody>
  66. <tr>
  67. <td><?php echo t('Title') ?> <span class="required">*</span></td>
  68. </tr>
  69. <tr>
  70. <?php if ($useCustomObjectDetails) { ?>
  71. <td><input type="text" id="title" name="title" autocomplete="off" value="<?php echo $customObject->getTitle(); ?>" style="width: 95%"></td>
  72. <?php } else { ?>
  73. <td><input type="text" id="title" name="title" autocomplete="off" value="<?php echo $_POST['title'] ?>" style="width: 95%"></td>
  74. <?php } ?>
  75. </tr>
  76. <tr>
  77. <td><?php echo t('Custom Object Content') ?> <span class="required">*</span></td>
  78. </tr>
  79. <tr>
  80. <?php if ($useCustomObjectDetails) { ?>
  81. <td>
  82. <?php Loader::packageElement('editor_init', 'custom_objects_demo'); ?>
  83. <textarea
  84. id="ccm-content-amenity"
  85. class="advancedEditor ccm-advanced-editor"
  86. name="content"
  87. style="width: 580px; height: 380px">
  88. <?php echo ($customObject->getContentEditMode()); ?>
  89. </textarea>
  90. </td>
  91. <?php } else { ?>
  92. <td>
  93. <?php Loader::packageElement('editor_init', 'custom_objects_demo'); ?>
  94. <textarea
  95. id="ccm-content-amenity"
  96. class="advancedEditor ccm-advanced-editor"
  97. name="content"
  98. style="width: 580px; height: 380px">
  99. <?php echo ($_POST['content']); ?>
  100. </textarea>
  101. </td>
  102. <?php } ?>
  103. </tr>
  104.  
  105. </tbody>
  106. </table>
  107.  
  108. </div>
  109.  
  110. <div class="ccm-pane-footer">
  111. <div class="ccm-buttons">
  112. <?php if ($useCustomObjectDetails || $isUpdate) { ?>
  113. <input type="hidden" name="update" value="1" />
  114. <input type="hidden" name="isUpdate" value="1" />
  115. <?php if ($useCustomObjectDetails) { ?>
  116. <input type="hidden" name="coID" value="<?php echo $customObject->getCustomObjectID(); ?>" />
  117. <?php } else { ?>
  118. <input type="hidden" name="coID" value="<?php echo $_POST['coID']; ?>" />
  119. <?php } ?>
  120. <?php print $ih->button(t('Cancel'), $this->url('/dashboard/custom_objects/search'), 'left', 'error') ?>
  121. <?php print $ih->submit(t('Update'), 'ccm-user-form', 'right', 'primary'); ?>
  122. <?php } else { ?>
  123. <input type="hidden" name="create" value="1" />
  124. <?php print $ih->button(t('Cancel'), $this->url('/dashboard/custom_objects/search'), 'left', 'error') ?>
  125. <?php print $ih->submit(t('Add'), 'ccm-user-form', 'right', 'primary'); ?>
  126. <?php } ?>
  127. </div>
  128. </div>
  129.  
  130. </form>
  131. <?php } ?>
  132. <?php echo Loader::helper('concrete/dashboard')->getDashboardPaneFooterWrapper(false); ?>

Screenshots of the Dashboard Pages

These are for 5.5, but the same code works on 5.6 it just looks a bit different.

Search Custom Objects List View Add / Edit View Saved Object Confirmation Confirm Delete Screen Deleted Object Success Message

Moving to the Front End

Now that we have our dashboard interface working to add / edit and update our Custom Objects, we need to move on to making the front end work.  There are several ways that we could approach this - we could make a "Custom Objects List" block and output them.  What I want to do for this one is actually make a page type which displays all of the object titles in a list on the sidebar, then clicking the title swaps out the content in the main area of the page.  I also want this to update the url with the title of the Custom Object converted to lowercase-with-dashes so that people can bookmark links.  Getting real pretty urls like this is pretty easy from a page type controller, but much harder to do with blocks. 

One thing that's worth noting - because of the way that the View::url() function works, you will get index.php in your links, even if you have pretty urls turned on and your .htaccess file working.  To avoid this, you can put define("URL_REWRITING_ALL", true); in your site.php file and it will go away.

The Custom Object List Page Type Controller

The first thing that we need to do is create a controller for our Page Type.  It only needs two methods, one for on_start() that loads up our list and model, then adds a css file to the header to format the output.  If you are making a custom page type within a theme, you probably don't need to add extra css.

The next part is really the key to getting it to look like these are different 'pages' in the URL.  Normally the view() function of a Page Type doesn't take any arguments, but we're adding one here, which is the escaped title of the custom object we want to display.  By giving it a blank default argument we don't have to have it in the URL to begin with. We first get the full list of custom objects, then check to see if we have a $customObject argument or not.  If there is no argument, then we set the 'selected' object to be the first item in the array.  Otherwise, we unhandle the argument using the text helper, then call the static method CustomObject::getByTitle($customObjectTitle) and set that as the selected Custom Object.

We could have created a different method, perhaps view_object($object_handle) and then linked to that, and call the view() function after.  I think it's a little cleaner in the URLs to just have it right off the parent page handle, which is why I'm doing it this way.

Here's the controller:

  1. <?php
  2.  
  3. defined('C5_EXECUTE') or die("Access Denied.");
  4.  
  5. class CustomObjectListPageTypeController extends Controller {
  6.  
  7. public function on_start() {
  8. Loader::model('custom_object', 'custom_objects_demo');
  9. Loader::model('custom_object_list', 'custom_objects_demo');
  10. $html = Loader::helper('html');
  11. $this->addHeaderItem($html->css('custom_object_list.css', 'custom_objects_demo'));
  12. }
  13.  
  14. public function view($customObject = "") {
  15. $txt = Loader::helper('text');
  16. $allCustomObjects = CustomObjectList::getAllCustomObjects();
  17. $this->set('allCustomObjects', $allCustomObjects);
  18.  
  19. if ($customObject == "") {
  20. $selectedCustomObject = $allCustomObjects[0];
  21. } else {
  22. $customObjectTitle = $txt->unhandle($customObject);
  23. $selectedCustomObject = CustomObject::getByTitle($customObjectTitle);
  24. }
  25.  
  26. $this->set('selectedCustomObject', $selectedCustomObject);
  27. }
  28.  
  29. }
  30.  
  31. ?>

The Page Type View

Now that we have a controller, we need to create a view for it.  This part is actually pretty simple.  First off we're going to loop over the list of all the Custom Objects, outputting the title and a link for each one pointing back at the controller's view method.  Then we use another package element to output the content of the selected custom object in the main area of the page.  Technically you don't need to use an element here, you could just put your code directly in the page.  Conversely, it might be better to do the sidebar as a package element as well.  Really you could do it a lot of different ways.

Here's the view.  Since this is a page type within a package, it will be displayed within the 'view.php' file in your theme.  To make it a full page in your theme, you would want to make a file custom_object_list.php in your theme folder and style that however you want, copying the code from the package page type to where it should go in your theme.

  1. <?php
  2. Loader::model("custom_object", "custom_objects_demo");
  3. $txt = Loader::helper("text");
  4. $c = Page::getCurrentPage();
  5. $filter_custom_objects_url = BASE_URL . View::url($c->getCollectionPath());
  6. ?>
  7. <div class="left">
  8. <h2><?php echo t("Custom Objects"); ?></h2>
  9. <ul id="custom-objects-list">
  10. <?php
  11. $selectedCustomObjectID = intval($selectedCustomObject->getCustomObjectID());
  12. foreach ($allCustomObjects as $customObject) {
  13. $title = $customObject->getTitle();
  14. $handle = $txt->sanitizeFileSystem($title);
  15. $coID = $customObject->getCustomObjectID();
  16. if ($selectedCustomObjectID == $coID) {
  17. ?>
  18. <li class="selected">
  19. <?php echo $title; ?>
  20. </li>
  21. <?php } else { ?>
  22. <li>
  23. <a href="<?php echo $filter_custom_objects_url . $handle; ?>">
  24. <?php echo $title; ?>
  25. </a>
  26. </li>
  27. <?php } ?>
  28. <?php } ?>
  29. </ul>
  30. </div>
  31. <div class="right">
  32. <?php Loader::packageElement("custom_objects/frontend_display", "custom_objects_demo", array("customObject" => $selectedCustomObject)); ?>
  33. </div>

The Frontend Display Package Element

This is really pretty basic, but here it is anyway:

  1. <?php
  2. defined('C5_EXECUTE') or die("Access Denied.");
  3. $title = $customObject->getTitle();
  4. $content = $customObject->getContent();
  5. ?>
  6. <h1><?php echo $title;?></h1>
  7. <div class="content">
  8. <?php echo $content;?>
  9. </div>

Frontend Screenshot

Front End Display

Wrapping It Up

I hope this helps make things a bit clearer to people on how to use this built in functionality in concrete5.  It seems like a lot of people know how to modify a page list a bit or create a custom block or even do dashboard pages.  But creating powerful CRUD interfaces for custom objects isn't something you see very much.  I think a lot of people default to doing everything with pages since that makes nice urls easy, and you can add in attributes pretty easily to extend their content.  Sometimes, though a page is just too much overhead.  If you just have a simple database item that you want to display, using a method like I've outlined here can make your pages work a lot faster.

One of the things I hear a fair amount about concrete5 is that it's kind of a 'toy' CMS - that it's so pretty and easy to use for the site owner that it can't have powerful tools for developers.  Hopefully this writeup helps dispel some of that myth.  You can create nearly anything that you want with it. 

Actually extending this method out a bit is possible too.  One thing that I want to learn to do is to create attribute categories for the custom object models.  That would let you do pretty much anything that you want.  You can already get attribute filtering capability even with this though - by storing a cID or uID or fID in your custom object database, you can then join to the search index attributes table for that item type.  Just put a file, user or page picker on your front end form and you can get the ID.  Or you can create a page on save, then store the cID from that.  Once you have one of these objects, you can start adding attributes to your edit forms and apply them to your object when you save.  You can also use other lists and objects to create 'parent' objects that contain lists of secondary objects and apply the filter methods through your table joins. 

But all of that will have to be the subject of another tutorial.  This one is already long enough, I think. :)

If you want to try out the files on your server or get a closer look at the code, you can download it here or browse it on github.  It's in package format, so unzipping it in your packages directory should make it available to install from the dashboard.  Checking out the github repo should be done into a folder called custom_object_demo - it doesn't have the parent folder included in the repo.  It should work for 5.5 and 5.6, but not earlier versions of concrete5.  Those used pretty much the same overall structure, but the dashboard styling was much different.  I've also set up a demo site where you can try things out too, http://customobjects.werstnet.com - the user name ic coAdmin and the pass is abc123.

Please leave any questions or comments below - I'll try to answer anything that I can, but I don't know everything. 

If you find this useful, please consider sending me a tip through paypal - I don't really make anything off of setting up demo files and tutorials.  I'd really appreciate it, and would be more likely to do these more often if it was actually bringing in some income. ;) 

blog comments powered by Disqus