Example: News Feed
In this example, we want to add a page that allows users to add news articles, as well as a page to list the articles.
This example assumes that you have already set up a webserver, installed Knickers, created a database, and created an app skeleton with users.
Create table
First, we need a table to track news events.
Besides the article itself, we want to know who created the news article and when it was created and last updated.
If you're using PostgreSQL, here's a table creation statement:
create table news_articles ( news_article_id serial, creator_id int, updated timestamp, created timestamp, subject varchar, article text, primary key(news_article_id));
Or if you prefer MySQL:
create table news_articles ( news_article_id int auto_increment, creator_id int, updated timestamp, created timestamp, subject varchar(128), article text, primary key(news_article_id));
Terminology Alert!
In Knickers, we call a page that allows you to view, edit, or create a new item a VEC , and a page that allows you to search/filter/sort a list of existing items is a SFS . Both of these require a PHP representation of this table; in Knickers, this is called a Thing (you may have heard this called a "model" in other frameworks). Things take care of getting a single record from a given table and applying any "business logic" required.
To translate the data in the Thing's fields into something viewable on the user interface, we use Components . For example, a Date Component might put the day/month in a different order depending on the user's Locale . We use a ComponentFinder to coordinate which Components get used for a given Thing (more specifically, a ComponentFinderDataProvider).
The Class Builder
Yikes! Sounds like a lot of files. Not to worry, the class builder can help. It will create the Thing, ComponentFinder, SFS, and VEC for your table. Some tweaking may be required to get it the way you want it, but it's a decent start. Here's how to run the class builder for this example:
cd [KNICKERS_APP_ROOT] [KNICKERS_ROOT]/scripts/class_builder.php news_articles
The class builder asks a lot of questions about the fields in your table; for most of the questions, it makes a guess at the answer so you only have to hit return if you agree. However, there are some that it can't guess at. The first question it will ask is if the name it is going to use for the classes makes sense; it makes an attempt to guess singular and plural forms, as well as capitalization.
Next it will ask if you want to accept defaults where possible; for now, answer 'y'. This means that it will simply use its guessed answer when it has one. Obviously, if you want to examine every question, press 'n' instead -- it just takes more time.
It will stop while working on the creator_id field, because it can't find a 'Thing_Creator'. This is ok; the creator_id is just a user id, and Knickers has a built-in user mechanism. If you haven't created the users database table yet, or want more info on Knickers users, click here.
So, "File does not exist. Will you be creating Thing_Creator later (y/n; also answer "n" if the name is incorrect and you want to change it)", answer 'n' and make the name "Thing_User" (we can create this later if needed).
After that, the builder should finish and you should be able to visit your.domain.com/NewsArticles !Next Steps
Visting the NewsArticles page, you'll see a bunch of inputs -- these allow you to filter the search results. Some of the filters might not make much sense yet, but it doesn't matter since we don't have any articles to search for! Click the "create new" link to jump to the news article VEC page.
On the NewsArticleAdmin page, you'll see the same inputs; but some of them we don't want. For example, you want the creator to be set to the current user when an article is created, not based on a drop down selection. Also, the created and updated dates should be set automatically, not from user input.
First thing to do is remove these inputs from the template. If we had not accepted the defaults in the class builder, we could have told it to leave these fields off the VEC; but it is simple enough to remove them. Open the template file, which is located at [KNICKERS_APP_ROOT]/common/tpl/html/Panel_NewsArticleAdmin.tpl, and look for the tags like {NEWSARTICLE.CREATORS.ERROR}, {NEWSARTICLE.CREATORS.INPUT}. Remove the corresponding table cells and tags. What, you say? Where are the <input> tags? That's part of the Knickers magic -- Components create the inputs for you!
Current User, Current Date
To set the current user as the creator when a new article is created, crack open the NewsArticle ComponentFinder, located at [your application]/common/cls/Component/ComponentFinder_NewsArticle.class.php. Add the following function:
/** * Need to set in the current user as the "creator" * *@access public *@return void */ function postSetCFDPAction() { if(is_a($this->CFDP, 'Storable') && $this->CFDP->isNew()) $this->CFDP->set('CREATOR', $this->user); }
You might ask, "why are we adding this function to the ComponentFinder instead of the Thing?". The reason is that the ComponentFinder knows the current context (user, locale, etc), whereas Thing does not.
To set the updated and created timestamps, we go into the Thing. During the normal process of storing a VEC, the data is first validated in Thing isValid(), and then stored by Thing store(). However, there are several points in that chain where you can add functionality custom to your Thing.
The actual chain of events is more like preValidateAction(), isValid(), customValidate(), preCreateAction() OR preUpdateAction(), store(), postCreateAction() OR postUpdateAction(). All of those
except
isValid() and store() are meant to be overridden. Generally, if you need to make modifications to the Thing's data during the storage process, you would put them in preValidateAction() to ensure that those modifications get validated. However, there are times when you only want to do something when a record is going to be created as opposed to updated - and Thing doesn't know which is going to happen for sure until storage is underway. Setting these timestamps is one of those cases.So, add these functions to your Thing_NewsArticle:
/** * Set our timestamps accordingly * *@access protected *@return void */ function preCreateAction() { $this->set('CREATED', new StorageCMD_Now()); $this->set('UPDATED', new StorageCMD_Now()); } /** * Set our timestamps accordingly * *@access protected *@return void */ function preUpdateAction() { $this->set('UPDATED', new StorageCMD_Now()); }
There are a number of ideas to comment on here. A different way to handle this would be to use database triggers -- in fact, that's why we put the "updated" field in the table before "created", because by default, MySQL puts a trigger on the first timestamp field in a table to update it whenever the record is updated. We prefer to put this knowledge in the Thing instead, however, because it allows us to use different databases without counting on those default behaviours existing. (Nothing against database triggers -- they are very useful -- but this is a case where one database system does something by default that others do not, and it can be very confusing.)
Another new item introduced here is the StorageCMD_Now class. In Knickers, when you are working with a VEC, there are actually several layers of abstraction between the page and the database. Closest to the database are the DBA classes, which in this case are used by a Storage class, which is, in turn, used by a Thing. Finally, the Thing is used by a ComponentFinder.
Yeesh.
The point is that the Thing does not have direct access to the database; it is the job of the Storage class to translate Thing's data into useable database query. However, there are some cases when you need to pass along information that is not meant to be stored value - instead you want database to provide the value. In this case, we want the database's current timestamp, and in short, StorageCMD_Now() tells Storage to do that."That seems complicated; why not just generate the timestamp locally intsead of using the database timestamp?" You can certainly do that. However, when you are in a load balanced, multi server environment, we prefer to use the database, which is a central entity, for record timestamps rather than counting on all of the web servers' clocks to always be synchronized.
Component Format
Another modification we want to make is that the input for the article text should be a text area, not a regular text input. There are a few different ways to modify inputs; one is to tell the ComponentFinder to use a different Component for a given field. Normally, ComponentFinder selects a Component based on the Thing's field type; so, for ARTICLE, which is designated as a 'Text' type, Component_Text gets used (the default Component type is designated in the Parcel; in this case, Field_Text). To tell ComponentFinder to use a different Component, add this member variable to ComponentFinder_NewsArticleAdmin:
var $nonDefaultFields = array('ARTICLE' => 'Component_TextArea');
Not everyone should create articles!
And you want to only allow "logged in" users to create articles... In your Panel_NewsArticleAdmin, add this function:
/** * Only allow logged in users to add articles * *@access public *@return boolean */ function isAvailable() { return $this->lm->isViewerLoggedIn(); }
Obviously you might want to further modify that to only allow a specific group of users make modifications -- not just anyone that is logged in -- but this is a start.
Search Filters
Assuming you've created a NewsArticle, now you can go back to the search page. We want to clean up the filter controls; remove the article input (which is now displaying as a textarea). The default date filters are also relatively useless; what we want is, for example, to find any articles created or updated since a given date. We need to filter by "created since" -- but as that is not a field in Thing_NewArticle, we need something new. Note that it's not just a field; we actually want a filter where the minimum date is the date provided. So, we want to add a Filter, but we don't know the value yet -- so we add a FilterFragment.
We have a couple options here; we can either add it to our ComponentFinder that we've already modified, or we can add it to the Panel. Which one you chose to do depends on if you want to reuse the logic elsewhere or not. If using our ComponentFinder:
/** * Need to set in the current user as the "creator" * *@access public *@return void */ function postSetCFDPAction() { if(is_a($this->CFDP, 'Relatable') && $this->CFDP->isNew()) $this->CFDP->set('CREATOR', $this->user); if(is_a($this->CFDP, 'Searchable')) { $this->CFDP->addFilterFragment( new FilterFragment('CREATED_SINCE', '>=', 'CREATED', null, 'Date')); $this->CFDP->addFilterFragment( new FilterFragment('UPDATED_SINCE', '>=', 'UPDATED', null, 'Date')); } }
Or, we could set it in our Panel:
/** * Add a filter fragment to our CFDP * *@access protected *@return void */ function postCFCreationAction() { $this->CFDP->addFilterFragment( new FilterFragment('CREATED_SINCE', '>=', 'CREATED', null, 'Date')); $this->CFDP->addFilterFragment( new FilterFragment('UPDATED_SINCE', '>=', 'UPDATED', null, 'Date')); }
Don't forget to change the CREATED inputs in the template to CREATED_SINCE, and UPDATED to UPDATED_SINCE!
Translate!
Now let's say we want to translate our panel into another language. To help us here, the text tagger script will add TXT tags to a template and create a dictionary file for the template. Let's use the article VEC panel as an example:
cd [KNICKERS_APP_ROOT] [KNICKERS_ROOT]/scripts/text_tagger.php common/tpl/html/Panel_NewsArticleAdmin.tpl
It will tell you that it created a dictionary file at [KNICKERS_APP_ROOT]/dicts/common/en_US/Panel_NewsArticleAdmin.tpl.dict. If you look at the contents of this file, you will see that it is simply an array. To make this page available in another language, copy this file into the appropriate langauge directory and translate the array values (not the keys!). For example, if you wanted to create a French version, create a [KNICKERS_APP_ROOT]/dicts/common/fr_FR directory, copy the template dictionary file from the en_US directory into this directory, and translate it. Then, if your browser language preference is set to French, or there is a "locale=fr_FR" in the URL, or the user is logged in and has a locale setting of fr_FR, they will see that version instead. Easy!