-
Notifications
You must be signed in to change notification settings - Fork 37
Creating an entity list page
A set of abstract classes allows to quickly obtain a custom class for outputting a list of models that allow to optionally edit certain model properties directly on the list. Such lists are used exclusively in the administrator area.
Suppose we have a module named Test authored by Tester. His script directory is classes\XLite\Module\Tester\Test
It contains a model named Record - \XLite\Module\Tester\Test\Model\Record
Simple list doesn’t assume model editing.
Create a list class \XLite\Module\Tester\Test\View\ItemsList\Records
. It is inherited from \XLite\View\ItemsList\Model\Table
Define columns. Do this by defining the defineColumns method. Example:
protected function defineColumns()
{
return array(
'name' => array(
static::COLUMN_NAME => \XLite\Core\Translation::lbl('Name'),
static::COLUMN_LINK => 'record',
),
'comment' => array(
static::COLUMN_NAME => \XLite\Core\Translation::lbl('Comment'),
),
);
}
In the example, we have defined that are going to have 2 columns, Name and Comment, which would show the model properties, respectively name and comment. This is going to be done by default via an explicit caall to the model: $entity->getName()
and $entity->getComment();
.
Besides that, we have marked the column with the name identifier as a column with a link to the model editor page. The link is formed as \XLite\Core\Converter::buildURL(‘record’, ‘’, array(‘id’ => $entity->getUniqueIdentifier()))
.
This imposes a restriction that the model editor page must accept the model identifier in the argument named id.
Define model class name. This is done through the defineRepositoryName
method.
Example for our case:
protected function defineRepositoryName()
{
retunr ‘XLite\Module\Tester\Test\Model\Record’;
}
Define model search wrapper. Example for our case:
protected function getData(\XLite\Core\CommonCell $cnd, $countOnly = false)
{
$repo = \XLite\Core\Database::getRepo($this->defineRepositoryName());
return $countOnly
? $repo->countByConditions($cnd)
: $repo->findByConditions($cnd);
}
This method solves two problems at once:
- Obtaining the total length of elements matching search condition
- Obtaining a portion of elements matching search condition + limit condition (
$cnd->limit
) and order condition ($cnd->orderBy
)
All the conditions come as a special transport object $cnd
.
The actual problems are solved within the repository object of the model, which the list displays the elements for. That could be 2 different methods or one – that doesn’t matter.
Create a form where we are going to display the list. We need this form to see the context, where the AJAX-based list functions, such as updating the list on change of sorting and paginating, are going to run.
Create the form class \XLite\Module\Tester\Test\View\Form\Admin\Records
. It is inherited from \XLite\View\Form\ItemsList\AItemsList
Define the default target and action for the form:
protected function getDefaultTarget()
{
return 'records';
}
protected function getDefaultAction()
{
return 'update';
}
Since we are creating a simple list, the action is not going to be actually used.
In the template of the widget shown for the controller \XLite\Module\Tetster\Test\Controller\Admin\Records
, insert the code for calling the list within the form:
<widget class="XLite\Module\Tester\Test\View\Form\Admin\Records" name="list" />
<widget class="XLite\Module\Tester\Test\View\ItemsList\Records" />
<widget name="list" end />
The previous list had pagination. You can further simplify it if you don’t need pagination. We take the simple list, and...
Declare an “infinite” pager for the list. This is done through the getPagerClass method in the list class:
protected function getPagerClass()
{
return 'XLite\View\Pager\Admin\Model\Infinity';
}
As the result, the isPagerVisible()
method of the list object returns false, and the pagination block does not appear. After that, in the absense of list search, dynamic list update is not necessary, so adding the search form no longer makes sense.
Delete the form class for the list and delete the call for the form in the controller template.
Take the simple list and add the following to the list class:
The entire list is wrapped into a div, which you can extend the classes assigned to through the getContainerClass
method:
protected function getContainerClass()
{
return parent::getContainerClass() . ' records';
}
For each string, its classes are defined as an array in the defineLineClass()
method. And the method obtains both the sequence number of the model on the list and the actual model being displayed.
Extension example:
protected function defineLineClass($index, \XLite\Model\AEntity $entity)
{
$classes = parent::defineLineClass($index, $entity);
$classes[] = $entity->getComment() ? 'commented' : 'uncommented';
return $classes;
}
Here you can just do nothing; the column header class originally contains the column code. The method in charge of that is getHeadClass
.
Fortunately, here too you can just do nothing; the column class originally contains the column code. That is defined in the getColumnClass()
method of the \XLite\View\ItemsList\Model\Table
class.
Naturally, you can override it if certain conditions in the model do not require a separate class for not just as string with the model but for a specific column.
Extension example:
protected function getColumnClass(array $column, \XLite\Model\AEntity $entity = null)
{
$class = parent::getColumnClass($column, $entity);
if ($entity && $entity->getComment()) {
$class .= ‘ commented’;
}
return trim($class);
}
Suppose, we need to preformat the values of fields in our table. We can do that using:
- separate field output template
- separate field output widget
- separate field value getter method
- preprocessing field within list class
Let’s consider all 4 methods below by the example of the comment field; suppose, we need to wrap the field into the span tag.
Let’s modify the declaration of the field in the defineColumns
method as follows:
protected function defineColumns()
{
return array(
'name' => array(
static::COLUMN_NAME => \XLite\Core\Translation::lbl('Name'),
static::COLUMN_LINK => 'record',
),
'comment' => array(
static::COLUMN_NAME => \XLite\Core\Translation::lbl('Comment'),
static::COLUMN_TEMPLATE => ‘modules/Tester/Test/comment.tpl’,
),
);
}
Now, in the specified template, we write:
<span>{entity.getComment()}</span>
Let’s modify the declaration of the field in the defineColumns method as follows:
protected function defineColumns()
{
return array(
'name' => array(
static::COLUMN_NAME => \XLite\Core\Translation::lbl('Name'),
static::COLUMN_LINK => 'record',
),
'comment' => array(
static::COLUMN_NAME => \XLite\Core\Translation::lbl('Comment'),
static::COLUMN_CLASS => \XLite\Module\Tester\Test\View\Comment,
),
);
}
Now, in the widget template, write the same code as we wrote in the separate template example. The option with a separate widget is weightier and required only when a change in the appearance of the field actually requires certain calculations, doing some additional sampling.
By default, the field with the comment id will be retrieved from the model as $entity->getComment()
. But if the list has the getCommentColumnValue()
method, the value will be retrieved through that method. In our case:
protected function getCommentColumnValue(\XLite\Model\AEntity $entity)
{
return ‘<span>’ . $entity->getComment() . ‘</span>’;
}
If the list class has the preprocessComment()
method, the value obtained through $entity->getComment()
will be passed through it.
Example for our case:
protected function preprocessComment($value, array $column, \XLite\Model\AEntity $entity)
{
return ‘<span>’ . $value . ‘</span>’;
}
To edit fields directly on the list, we need to create custom classes for the fields related to the model, add the list modification action to the controller, and create a separate class with a floating panel with buttons. In our example, we are going to edit the comment field.
IMPORTANT!
To this moment, there is a restriction that you may not combine several simple fields within one Inline. Therefore, you may not create, for example, fields for editing time periods or combinations of minimum and maximum values.
Create the \XLite\Module\Tester\Test\View\FormField\Inline\Comment
class. It is inherited from \XLite\View\FormField\Inline\Input\Text
.
Define the shortName
property; it specifies which module field is going to be edited.
Example for our case:
protected $shortName = 'comment';
Modify the declarations of the columns in the list class as follows:
protected function defineColumns()
{
return array(
'name' => array(
static::COLUMN_NAME => \XLite\Core\Translation::lbl('Name'),
static::COLUMN_LINK => 'record',
),
'comment' => array(
static::COLUMN_NAME => \XLite\Core\Translation::lbl('Comment'),
static::COLUMN_CLASS => \XLite\Module\Tester\Test\View\FormField\Inline\Comment,
),
);
}
Add the action method to the controller of the page with the list, \XLite\Module\Tester\Test\Controller\Admin\Records
:
protected function doActionUpdate()
{
$list = new \XLite\Module\Tester\Test\View\ItemsList\Records();
$list->processQuick();
}
In this method, we create the list and launch the common processing of it using the input data. This launch routine will process both the creation and update of the new modules (we are going to cover this further), as well as the update of the new data (that’s what we actually need).
Add the class for the floating panel with buttons.
In the list class, add the panel declaration method, getPanelClass()
protected function getPanelClass()
{
return 'XLite\Module\Tester\Test\View\StickyPanel\Coupons';
}
Create the \XLite\Module\Tester\test\View\StickyPanel\Coupons
class, inheriting it from \XLite\View\StickyPanel\ItemsListForm
.
Leave the body of the class empty, so that we have just a separate class, which other modules could extend.
Behaviors include:
- marking model for deletion
- moving model within list
- marking model
- enabling/disabling model
All the behaviors, except marking, are implemented as editable list fields; therefore, they require performing steps 2 and 3 from the previous example. A model marking comes to the controller as a select array with the id of the selected models used as keys.
Behaviors are activated through the list class methods:
- marking model for deletion:
isRemoved()
must return true - moving model within list:
getSortableType()
must return either self::SORT_TYPE_MOVE (if the position of the model on the list can be changed by dragging) or self::SORT_TYPE_INPUT (if the position of the model on the list can be changed via an input-box) - marking model:
isSelectable()
must return true - enabling/disabling model:
isSwitchable()
must return true
IMPORTANT!
Behaviors dictate severe restrictions on model field names. In particular:
- model enabling/disabling must be handled by the enabled field of boolean
- model position on the list must be determined by the position field of integer
The creation of new models is incorporated directly in the widget list. To enable the direct creation:
Add reference to the inline fields to be created on the column list as follows:
protected function defineColumns()
{
return array(
'name' => array(
static::COLUMN_NAME => \XLite\Core\Translation::lbl('Name'),
static::COLUMN_LINK => 'record',
static::COLUMN_CREATE_CLASS => \XLite\Module\Tester\Test\View\FormField\Inline\Name,
),
'comment' => array(
static::COLUMN_NAME => \XLite\Core\Translation::lbl('Comment'),
static::COLUMN_CLASS => \XLite\Module\Tester\Test\View\FormField\Inline\Comment,
static::COLUMN_CREATE_CLASS => \XLite\Module\Tester\Test\View\FormField\Inline\Comment,
),
);
}
Respectively, if we don’t have an inline field for the name field, now we need to create it to enable creating models directly in the list. With all that, for currently existing models that field is absent (as the COLUMN_CLASS directive for the name column is absent). It is assumed that in the list the model will be created in the limited version, and the full editing will still take place on a separate page.
Override the isInlineCreation
method in the list class, so that it returns either static::CREATE_INLINE_TOP or static::CREATE_INLINE_BOTTOM . Respectively, the new fileds will be added at the top of the list or at the bottom of the list. Please also remember that there is always only one new model creation button on the list, and if our isCreation
method returns anything other than CREATE_INLINE_NONE, the button will send us to a separate page for creating a new model. Therefore, our isCreation
method must either be not overridden or return REATE_INLINE_NONE.
For example, we have decided to add new fields at the top:
protected function isInlineCreation()
{
return static::CREATE_INLINE_TOP;
}
protected function isCreation()
{
return static::CREATE_INLINE_NONE;
}
For this purpose, we will need to customize:
- list class: search parameters are also its widget parameters
- repository class for model to be shown on list
- create search form class
- create search form internals
Suppose, we want to search by name, using a parameter with a universal name substring.
We need to create a widget parameter and define its correspondence with the repository search parameter (see getSearchParams()
method), define it in the parameters stored in session (see defineRequestParams()
method) and define the universal translation of the widget parameters to parameters of the cell that goes to the repository as search arguments transport (see getSearchCondition()
method).
Customize the list class for our example:
const PARAM_SUBSTRING = 'substring';
protected function defineWidgetParams()
{
parent::defineWidgetParams();
$this->widgetParams += array(
self::PARAM_SUBSTRING => new \XLite\Model\WidgetParam\String(
'Substring', ''
),
);
}
static public function getSearchParams()
{
return array(
\XLite\Module\CDev\Suppliers\Model\Repo\Supplier::SEARCH_SUBSTRING => self::PARAM_SUBSTRING,
);
}
protected function defineRequestParams()
{
parent::defineRequestParams();
$this->requestParams[] = self::PARAM_SUBSTRING;
}
protected function getSearchCondition()
{
$result = parent::getSearchCondition();
foreach (\XLite\View\ItemsList\Product\Customer\Search::getSearchParams() as $modelParam => $requestParam) {
$paramValue = $this->getParam($requestParam);
if ('' !== $paramValue && 0 !== $paramValue) {
$result->$modelParam = $paramValue;
}
}
return $result;
}
In the repository class of the model that shows the list, we need to create a wrapper for searching by parameter. To do so, we need to create a search parameter and also create a method for it that would be in charge of preparing the request. Example for our case:
const SEARCH_SUBSTRING = 'substring';
protected function getHandlingSearchParams()
{
return array(
self::SEARCH_SUBSTRING,
);
}
protected function prepareCndSubstring(\Doctrine\ORM\QueryBuilder $queryBuilder, $value)
{
if (!empty($value)) {
$queryBuilder->andWhere('r.name = :substring')
->setParameter('substring', $value);
}
}
Create a form that would lead to a separate action named search. Let’s name it XLite\Module\tester\Test\View\Form\Search
Create a template for calling the form. Example for our case:
<widget class="\XLite\Module\Tester\Test\View\Form\Search" name="search" />
<table>
{displayViewListContent(#tester.test.list.search.conditions#)}
</table>
<widget name="search" end />
Place the call for this template above the list.
Through the list named tester.test.list.search.conditions
, we are going to add the search box and the search button to the table.
Template with search box:
{**
* @ListChild (list="tester.test.list.search.conditions")
*}
<tr>
<td colspan="2"><widget class="XLite\View\FormField\Input\Text" fieldName="substring" value="{getCondition(#substring#):r}" fieldOnly="true" /></td>
<td>{displayInheritedViewListContent(#tester.test.list.search.conditions.actions#)}</td>
</tr>
Template with search button:
{*
*
* @ListChild (list="tester.test.list.search.conditions.actions")
*}
<widget class="\XLite\View\Button\Submit" label="{t(#Search#)}" />
In the controller, we need to add an action for parsing the search + methods for returning the search condition by its name (see the getCondition()
and getConditions()
methods).
Here is what it’s going to look like in our case:
protected function doActionSearch()
{
$cellName = \XLite\Module\CDev\Suppliers\View\ItemsList\Model\Suppliers::getSessionCellName();
\XLite\Core\Session::getInstance()->$cellName = $this->getSearchParams();
}
protected function getSearchParams()
{
$searchParams = array();
foreach (
\XLite\Module\CDev\Suppliers\View\ItemsList\Model\Suppliers::getSearchParams() as $requestParam
) {
if (isset(\XLite\Core\Request::getInstance()->$requestParam)) {
$searchParams[$requestParam] = \XLite\Core\Request::getInstance()->$requestParam;
}
}
return $searchParams;
}
public function getCondition($paramName)
{
$searchParams = $this->getConditions();
if (isset($searchParams[$paramName])) {
$return = $searchParams[$paramName];
}
return isset($searchParams[$paramName])
? $searchParams[$paramName]
: null;
}
protected function getConditions()
{
$cellName = \XLite\Module\CDev\Suppliers\View\ItemsList\Model\Suppliers::getSessionCellName();
$searchParams = \XLite\Core\Session::getInstance()->$cellName;
if (!is_array($searchParams)) {
$searchParams = array();
}
return $searchParams;
}