Feature Request - Nested Sets (items with hierarchical parent/child relationships) #752

Open
opened 2021-05-22 15:48:25 +00:00 by Obscerno · 3 comments
Obscerno commented 2021-05-22 15:48:25 +00:00 (Migrated from github.com)

I'm following up on #751. I think a good way to implement this would be to add a 'Parent' field type that triggers these changes to the view when included in the form.

I'm going to lay out all of the steps I took to get this working to help with automating it, but I'll also write it like a tutorial in case someone needs to use it in the meantime (at the very least I will probably use it). This was the article I used as a starting point by the way: https://docs.joomla.org/Using_nested_sets

I'll summarize gotchas at the bottom; might be helpful for @Llewellynvdm.

EDIT: I fixed a few spots in the code where I copied the view and component name from my own project, and clarified that you have to edit your own in there.

Create the fields

Step one is to create some columns that are required by the JTableNested class (which we'll be setting up soon). Some of them are already created by JCB, but you'll have to create in the rest. You can do that using fields. The required fields are:

Title

Set the type to "Text"

The 'name' option for this field must be set to 'title' so do that. I think that in JCB titles can have any name, but in this case JTableNested assumes that the field is named 'title'.

In the database tab, set the options so they're equivalent to varchar(255) NOT NULL.

Lft, Rgt, Level, and Path

These fields should all be set to the "Hidden" type. They are used by JTableNested to maintain the tree structure.

Like with the title, it's important to get the name and database details right. I'll list them below for each:

  • lft – int(11) NOT NULL DEFAULT '0'
  • rgt – int(11) NOT NULL DEFAULT '0'
  • level – int(10) unsigned NOT NULL DEFAULT '0'
  • path – varchar(255) NOT NULL DEFAULT ''

Parent Id

This field will let items in your view add other items as their parents.

To start, create a field with a "Custom" type. Then fill in the fields with these values:

  • type: parentids
  • name: parent_id (mandatory)
  • table: Insert your view's table name here
  • value_field: title
  • view: empty
  • views: empty

Note that if you want to reuse this field in the same component but a different view, you'll have to make a copy and change the 'type' field. You'll of course also have to change all of the references to your view, but that type change isn't obvious so that's why I mention it.

If you include 'view' and 'views' it adds an edit button to the dropdown, but we don't want that.

We're going to want to add code in the text field at the very bottom, but before we do that, set your database options so they're equivalent to int(10) NOT NULL DEFAULT '0'.

Okay, now it's time to add the code that generates the dropdown options. This lets the user select what item they want to make the parent of the item they're editing.

Make sure you replace 'yourview' and 'Yourcomponent' with the item view and component name for your project.

// Note: a lot of this is lifted straight out of com_categories' categoryparent field.

// Get the user object.
$user = JFactory::getUser();

// Get the database object.
$db = JFactory::getDBO();
$query = $db->getQuery(true);
$query->select($db->quoteName(array('a.###ID###','a.###TEXT###', 'a.level'),array('###ID###','###CODE_TEXT###', 'level')));
$query->from($db->quoteName('###TABLE###', 'a'));

// Prevent parenting to children of this item.
if ($id = $this->form->getValue('id'))
{
    $query->join('LEFT', $db->quoteName('###TABLE###') . ' AS p ON p.id = ' . (int) $id)
          ->where('NOT(a.lft >= p.lft AND a.rgt <= p.rgt)');
}

$query->where($db->quoteName('a.published') . ' IN (0,1)');
$query->order('a.lft ASC');

// Implement View Level Access (if set in table)
if (!$user->authorise('core.options', '[[[com_component]]]'))
{
    $columns = $db->getTableColumns('###TABLE###');
    if(isset($columns['access']))
    {
        $groups = implode(',', $user->getAuthorisedViewLevels());
        $query->where('a.access IN (' . $groups . ')');
    }
}
$db->setQuery((string)$query);
$items = $db->loadObjectList();

// Build the list options.
$options = array();

// If no items are returned, add the table's root node.
if (!$items) {
    $table = JTable::getInstance('yourview', 'YourcomponentTable');
    $rootId = $table->getRootId();
    $options[] = JHtml::_('select.option', "$rootId", 'Select an option');
}

// Format the items.
foreach($items as $item)
{
    // Translate ROOT
    if ($item->level == 0) {
        $item->###CODE_TEXT### = JText::_('JGLOBAL_ROOT_PARENT');
    }

    $itemLevelIndicator = str_repeat('- ', !$item->level ? 0 : $item->level - 1);
    $options[] = JHtml::_('select.option', $item->###ID###, $itemLevelIndicator . $item->###CODE_TEXT###);
}

return $options;

If you haven't already, create your view. Give it names, a short description, and save.

Now, go to the "Fields" tab, and click the "Edit" button for "Linked Fields". A new page will open.

Add the fields:

  • Parent Id - Select "Only in Linked List Views" from the "Admin behavior" dropdown. Configure to taste.
  • Title - Select "Show in All List Views" from the "Admin behavior" dropdown, and check Title. Configure to taste.
  • Alias - JCB comes with a good alias field; just use that. Select "Show in All List Views" and check "Alias". Configure to taste.
  • Lft, Rgt, Level, and Path - Leave default settings.

Save and close.

Add admin fields relations to the view

In the list view, we're going to want to display the nesting of the fields. The usual way Joomla does this is by adding characters like '–' before the title link to show how deeply it's nested. That's what we're going to set up now.

In the 'Fields' tab, there's a button that says "Edit admin fields relations for this admin view". Click on that.

Set "List Field" to your title field. Then, for the Join field select your level field.

Set "Join Type" to Custom Code, and in the Set field paste this code, replacing LEVEL and TITLE with the codes that JCB generates for you.

// Indicate how deeply nested the item is.
$leading = '';
if ($item->{LEVEL} > 2)
    $leading .= str_repeat('┊  ', $item->{LEVEL} - 2);
    
if ($item->{LEVEL} > 1)
    $leading .= '–  ';

$item->{TITLE} = $leading . $item->{TITLE};

Save and close.

Add custom code to the admin view

We're almost done in the view! Click the PHP tab and add this code to the following sections:

PHP getListQuery Method

// Exclude the root node.
$query->where($db->quoteName('a.level') . " > 0");

PHP getForm Method

// HACK: Including these items in the form interferes with the table's processing.
$form->removeField('lft');
$form->removeField('rgt');
$form->removeField('level');
$form->removeField('path');

That's it. Save and close.

Modify the table

Before we can continue, we need to compile. If you haven't already, add the view to your component, and compile. It will not be functional yet.

Now, open up your IDE and navigate to administrator/components/com_yourcomponent/tables/yourview.php.

Inherit from JTableNested

JTableNested is where all of the magic happens. It does like 95% of the work for us. If you want to dig around in the code, go to libraries/src/Table/Nested.php in your joomla installation. Okay let's get to the code.

Remove the line at the top that looks like:

class YourcomponentTableYourview extends JTable

... and replace it with:

/***[REPLACE<>$$$$]***/
class YourcomponentTableYourview extends JTableNested
/***[/REPLACE<>$$$$]***/

Make sure you're not just straight up copy pasting, as your class name will be different than this!

Modify the __construct() function

After the line:

parent::__construct('#__efsearch_search_item', 'id', $db);

... insert the following snippet:

/***[INSERT<>$$$$]***/
        $this->setColumnAlias('ordering', 'lft');
/***[/INSERT<>$$$$]***/

This is required to get ordering in the list view working properly. Note that this is a bit of a HACK: normally you wouldn't include an ordering column when using nested sets, but JCB adds it automatically.

Add table functions

Somewhere in the class, add these functions. I dropped them in under the __construct function. We'll be using them elsewhere in the code.

Make sure you replace the #_yourcomponent_yourview table name with one that fits your project.

/***[INSERT<>$$$$]***/
    // Insert a root item. Source: https://docs.joomla.org/Using_nested_sets
    public function addRoot() {
        $db = JFactory::getDbo();

      $query = $db->getQuery(true)
          ->insert('#_yourcomponent_yourview')
          ->set($db->quoteName('parent_id') . ' = 0')
          ->set($db->quoteName('lft') . ' = 0')
          ->set($db->quoteName('rgt') . ' = 1')
          ->set($db->quoteName('level') . ' = 0')
          ->set($db->quoteName('title') . ' = ' . $db->quote('root'))
          ->set($db->quoteName('alias') . ' = ' . $db->quote('root'))
          ->set($db->quoteName('access') . ' = 1')
          ->set($db->quoteName('path') . ' = ' . $db->quote(''));
      $db->setQuery($query);

      if(!$db->execute())
      {
          return false;
      }

      return $db->insertid();
    }

    // Get the root id (this override adds the root if it doesn't exist)
    public function getRootId() {
        $rootId = parent::getRootId();

        if ($rootId === false) {
            $rootId = $this->addRoot();
        }

        return $rootId;
    }
/***[/INSERT<>$$$$]***/

Modify the table's store() function

JTableNested does most of the work, but we have to manually tell it to add child nodes to their parents, and also help it rebuild the paths afterward. Just so you can see the context, I'm going to paste the entire function. The custom code you need to add is indicated by the custom code tags.

Edit: I replaced 'last-child' with 'first-child' in the calls to moveByReference and setLocation. Depending on your preference, you may want to use one or the other.

public function store($updateNulls = false)
{
    $date        = JFactory::getDate();
    $user        = JFactory::getUser();

    if ($this->id)
    {
        // Existing item
        $this->modified           = $date->toSql();
        $this->modified_by        = $user->get('id');
/***[INSERT<>$$$$]***/
        // If the parent node has changed, append this item to it.
        $node = parent::getTree($this->id)[0];
        if ($node->parent_id != $this->parent_id)
            parent::moveByReference($this->parent_id, 'first-child', $this->id);
/***[/INSERT<>$$$$]***/
    }
    else
    {
        // New yourview. A yourview created and created_by field can be set by the user,
        // so we don't touch either of these if they are set.
        if (!(int) $this->created)
        {
            $this->created = $date->toSql();
        }
        if (empty($this->created_by))
        {
            $this->created_by = $user->get('id');
        }
/***[INSERT<>$$$$]***/
        // Append this new item to its parent.
        parent::setLocation($this->parent_id, 'first-child');
/***[/INSERT<>$$$$]***/
    }
    
    if (isset($this->alias))
    {
        // Verify that the alias is unique
        $table = JTable::getInstance('yourview', 'Yourcomponent');

        if ($table->load(array('alias' => $this->alias)) && ($table->id != $this->id || $this->id == 0))
        {
            $this->setError(JText::_('COM_YOURCOMPONENT_YOURVIEW_ERROR_UNIQUE_ALIAS'));
            return false;
        }
    }
    
    if (isset($this->url))
    {
        // Convert IDN urls to punycode
        $this->url = JStringPunycode::urlToPunycode($this->url);
    }
    if (isset($this->website))
    {
        // Convert IDN urls to punycode
        $this->website = JStringPunycode::urlToPunycode($this->website);
    }

/***[REPLACE<>$$$$]***/
    // The below is taken from Joomla's JTableMenu object. (see /libraries/src/Table/Menu.php)
    if (!parent::store($updateNulls))
    {
        return false;
    }

    // Get the new path in case the node was moved
    $pathNodes = $this->getPath();
    $segments = array();

    foreach ($pathNodes as $node)
    {
        // Don't include root in path
        if ($node->alias !== 'root')
        {
            $segments[] = $node->alias;
        }
    }

    $newPath = trim(implode('/', $segments), ' /\\');

    // Use new path for partial rebuild of table
    // Rebuild will return positive integer on success, false on failure
    return $this->rebuild($this->{$this->_tbl_key}, $this->lft, $this->level, $newPath) > 0;
/***[/REPLACE<>$$$$]***/
}

Modify the table's check() function

We only want to replace one line in this function. At the very end, replace the line:

return true;

... with:

/***[REPLACE<>$$$$]***/
        // Check for a path. Taken from Joomla's JTableMenu object. (see /libraries/src/Table/Menu.php)
        if (trim($this->path) === '')
        {
            $this->path = $this->alias;
        }

        return parent::check();
/***[/REPLACE<>$$$$]***/

That's is for the table class!

Get list view ordering working

At this point your view should be working. You can add items, add a parent using the dropdown, and as you do this, JTableNested modified the lft and rgt fields to reflect the new nested order.

But in the list view, things are completely out of order, so we want to fix that. Doing so will involve overwriting a few files:

administrator/components/com_yourcomponent/models/yourview.php

Note that this is the model for the single item view, not for the list view.

Somewhere in the class, insert the following function:

/***[INSERT<>$$$$]***/
    /**
     * Method to save the reordered nested set tree.
     * First we save the new order values in the lft values of the changed ids.
     * Then we invoke the table rebuild to implement the new ordering.
     *
     * @param   array    $idArray   An array of primary key ids.
     * @param   integer  $lftArray  The lft value
     *
     * @return  boolean  False on failure or error, True otherwise
     *
     * @since   1.6
     */
    public function saveorder($idArray = null, $lftArray = null)
    {
        // Get an instance of the table object.
        $table = $this->getTable();

        if (!$table->saveorder($idArray, $lftArray))
        {
            $this->setError($table->getError());

            return false;
        }

        // Clear the cache
        $this->cleanCache();

        return true;
    }
/***[/INSERT<>$$$$]***/

It is taken straight out of Joomla's category component. Normally the model handles the saveorder function, but JTableNested has custom handling for it, so we want to forward along to it.

administrator/components/com_yourcomponent/models/yourviews.php

Note that this is the model for your list view, not your item view. You can tell the difference because the list view is plural.

In the getListQuery() function, find the lines:

// Add the list ordering clause.
$orderCol = $this->state->get('list.ordering', 'a.id');
$orderDirn = $this->state->get('list.direction', 'desc');

... and replace them with:

/***[REPLACE<>$$$$]***/
        // Sort by lft ascending by default: it displays the items in order.
        $orderCol = $this->state->get('list.ordering', 'a.lft');
        $orderDirn = $this->state->get('list.direction', 'asc');

        // HACK: rather than completely remove ordering, I'm just overriding its
        // behavior, but letting JCB think it's sorting by order.
        if ($orderCol == 'a.ordering') {
                $orderCol = 'a.lft';
        }
/***[/REPLACE<>$$$$]***/

administrator/components/com_yourcomponent/views/yourviews/view.html.php

Again, check that the view folder's name is plural. This is the list view we're modifying.

In the display() function, find the lines:

// Add the list ordering clause.
$this->listOrder = $this->escape($this->state->get('list.ordering', 'a.id'));
$this->listDirn = $this->escape($this->state->get('list.direction', 'DESC'));

... and replace them with:

/***[REPLACE<>$$$$]***/
        // Use 'ordering' as the default sort option. Note that in the model this is
        // overriden and 'lft' is used instead (see HACK comment there)
        $this->listOrder = $this->escape($this->state->get('list.ordering', 'a.ordering'));
        $this->listDirn = $this->escape($this->state->get('list.direction', 'ASC'));

        // Preprocess the list of items to find ordering divisions. (source: com_categories)
        foreach ($this->items as &$item)
        {
                $this->ordering[$item->parent_id][] = $item->id;
        }
/***[/REPLACE<>$$$$]***/

administrator/components/com_yourcomponent/views/yourviews/tmpl/default_body.php

Modify this template using the custom code snippets shown here:

<?php

// No direct access to this file
defined('_JEXEC') or die('Restricted access');

$edit = "index.php?option=com_yourcategory&view=yourview&task=yourview.edit";

?>
<?php foreach ($this->items as $i => $item): ?>
    <?php
        $canCheckin = $this->user->authorise('core.manage', 'com_checkin') || $item->checked_out == $this->user->id || $item->checked_out == 0;
        $userChkOut = JFactory::getUser($item->checked_out);
        $canDo = YourcomponentHelper::getActions('yourview',$item,'yourview');
/***[INSERT<>$$$$]***/
        // Get the parents of item for sorting. (source: com_categories)
        if ($item->level > 1)
        {
            $parentsStr = '';
            $_currentParentId = $item->parent_id;
            $parentsStr = ' ' . $_currentParentId;
            for ($i2 = 0; $i2 < $item->level; $i2++)
            {
                foreach ($this->ordering as $k => $v)
                {
                    $v = implode('-', $v);
                    $v = '-' . $v . '-';
                    if (strpos($v, '-' . $_currentParentId . '-') !== false)
                    {
                        $parentsStr .= ' ' . $k;
                        $_currentParentId = $k;
                        break;
                    }
                }
            }
        }
        else
        {
            $parentsStr = '';
        }

        // Below, include the sort attributes for nested sorting.
/***[/INSERT<>$$$$]***/
    ?>
<!--[REPLACE<>$$$$]-->	 
        <tr class="row<?php echo $i % 2; ?>" sortable-group-id="<?php echo $item->parent_id; ?>" item-id="<?php echo $item->id ?>" parents="<?php echo $parentsStr ?>" level="<?php echo $item->level ?>">
<!--[/REPLACE<>$$$$]-->	 
        <td class="order nowrap center hidden-phone">
        <?php if ($canDo->get('core.edit.state')): ?>
            <?php
                $iconClass = '';
                if (!$this->saveOrder)
                {
                    $iconClass = ' inactive tip-top" hasTooltip" title="' . JHtml::tooltipText('JORDERINGDISABLED');
                }
            ?>
            <span class="sortable-handler<?php echo $iconClass; ?>">
                <i class="icon-menu"></i>
            </span>
            <?php if ($this->saveOrder) : ?>
<!--[REPLACED$$$$]--><!--72-->
                <input type="text" style="display:none" name="order[]" size="5"
                value="<?php echo $item->lft; ?>" class="width-20 text-area-order " />
<!--[/REPLACED$$$$]-->
            <?php endif; ?>
        . . .

administrator/components/com_yourcomponent/views/yourviews/tmpl/default.php

Finally, insert the custom code snippet used here.

Make sure to replace 'yourview' so that it matches the item view in your project.

<?php

// No direct access to this file
defined('_JEXEC') or die('Restricted access');

JHtml::_('behavior.tooltip');
JHtml::_('behavior.multiselect');
JHtml::_('dropdown.init');
JHtml::_('formbehavior.chosen', '.multipleAccessLevels', null, array('placeholder_text_multiple' => '- ' . JText::_('COM_YOURCOMPONENT_FILTER_SELECT_ACCESS') . ' -'));
JHtml::_('formbehavior.chosen', 'select');
if ($this->saveOrder)
{
    $saveOrderingUrl = 'index.php?option=com_yourcomponent&task=yourview.saveOrderAjax&tmpl=component';
/***[REPLACE<>$$$$]***/
    // Set the final parameter, which is the nestedList option, to 1. This makes
    // it so when dragging parent items, their children are also moved with them.
    JHtml::_('sortablelist.sortable', 'yourviewList', 'adminForm', strtolower($this->listDirn), $saveOrderingUrl, '', '1');
/***[/REPLACE<>$$$$]***/
}
?>

EDIT May 17 2022: administrator/components/com_yourcomponent/models/forms/filter_yourviews.xml

I missed this one: you also need to make 'ordering' the default ordering option in the filter XML. Without this you occasionally get bugs where items are unexpectedly ordered by id.

In this file you want to change the line default="a.id DESC" to default="a.ordering ASC". You have to replace the entire node to make this change because XML doesn't allow you to add comments inside of nodes. The end result will look something like:

    <fields name="list">
<!--[REPLACE<>$$$$]-->
        <field
            name="fullordering"
            type="list"
            label="COM_CONTENT_LIST_FULL_ORDERING"
            description="COM_CONTENT_LIST_FULL_ORDERING_DESC"
            onchange="this.form.submit();"
            default="a.ordering ASC"
            validate="options"
        >
<!--[REPLACE<>$$$$]-->
        <option value="">JGLOBAL_SORT_BY</option>
        <option value="a.ordering ASC">JGRID_HEADING_ORDERING_ASC</option>
        <option value="a.ordering DESC">JGRID_HEADING_ORDERING_DESC</option>
        . . .

That's it!

You did it! Your list view now behaves a lot like the categories view does now, except you can do a lot more interesting things with it.

If you notice any issues let me know and I'll update this!

Gotchas

  • The title must have the name 'title'. JCB lets people use other names for their title field, so to preserve this flexibility you might have to override the JTableNested class (see libraries/src/Table/Nested.php)
  • I had to use a few hacks to make things work well with JCB. I've labeled them in comments with HACK. In particular, the ordering hack I used is a bit smelly. It works for me but probably isn't how you'll want to do it.
I'm following up on #751. I think a good way to implement this would be to add a 'Parent' field type that triggers these changes to the view when included in the form. I'm going to lay out all of the steps I took to get this working to help with automating it, but I'll also write it like a tutorial in case someone needs to use it in the meantime (at the very least I will probably use it). This was the article I used as a starting point by the way: https://docs.joomla.org/Using_nested_sets I'll summarize gotchas at the bottom; might be helpful for @Llewellynvdm. **EDIT:** I fixed a few spots in the code where I copied the view and component name from my own project, and clarified that you have to edit your own in there. ### Create the fields Step one is to create some columns that are required by the JTableNested class (which we'll be setting up soon). Some of them are already created by JCB, but you'll have to create in the rest. You can do that using fields. The required fields are: #### Title Set the type to "Text" The 'name' option for this field **must** be set to 'title' so do that. I think that in JCB titles can have any name, but in this case JTableNested assumes that the field is named 'title'. In the database tab, set the options so they're equivalent to `varchar(255) NOT NULL`. #### Lft, Rgt, Level, and Path These fields should all be set to the "Hidden" type. They are used by JTableNested to maintain the tree structure. Like with the title, it's important to get the name and database details right. I'll list them below for each: - lft – `int(11) NOT NULL DEFAULT '0'` - rgt – `int(11) NOT NULL DEFAULT '0'` - level – `int(10) unsigned NOT NULL DEFAULT '0'` - path – `varchar(255) NOT NULL DEFAULT ''` #### Parent Id This field will let items in your view add other items as their parents. To start, create a field with a "Custom" type. Then fill in the fields with these values: - type: parentids - name: parent_id **(mandatory)** - table: *Insert your view's table name here* - value_field: title - view: *empty* - views: *empty* Note that if you want to reuse this field in the same component but a different view, you'll have to make a copy and change the 'type' field. You'll of course also have to change all of the references to your view, but that type change isn't obvious so that's why I mention it. If you include 'view' and 'views' it adds an edit button to the dropdown, but we don't want that. We're going to want to add code in the text field at the very bottom, but before we do that, set your database options so they're equivalent to `int(10) NOT NULL DEFAULT '0'`. Okay, now it's time to add the code that generates the dropdown options. This lets the user select what item they want to make the parent of the item they're editing. *Make sure you replace 'yourview' and 'Yourcomponent' with the item view and component name for your project.* // Note: a lot of this is lifted straight out of com_categories' categoryparent field. // Get the user object. $user = JFactory::getUser(); // Get the database object. $db = JFactory::getDBO(); $query = $db->getQuery(true); $query->select($db->quoteName(array('a.###ID###','a.###TEXT###', 'a.level'),array('###ID###','###CODE_TEXT###', 'level'))); $query->from($db->quoteName('###TABLE###', 'a')); // Prevent parenting to children of this item. if ($id = $this->form->getValue('id')) { $query->join('LEFT', $db->quoteName('###TABLE###') . ' AS p ON p.id = ' . (int) $id) ->where('NOT(a.lft >= p.lft AND a.rgt <= p.rgt)'); } $query->where($db->quoteName('a.published') . ' IN (0,1)'); $query->order('a.lft ASC'); // Implement View Level Access (if set in table) if (!$user->authorise('core.options', '[[[com_component]]]')) { $columns = $db->getTableColumns('###TABLE###'); if(isset($columns['access'])) { $groups = implode(',', $user->getAuthorisedViewLevels()); $query->where('a.access IN (' . $groups . ')'); } } $db->setQuery((string)$query); $items = $db->loadObjectList(); // Build the list options. $options = array(); // If no items are returned, add the table's root node. if (!$items) { $table = JTable::getInstance('yourview', 'YourcomponentTable'); $rootId = $table->getRootId(); $options[] = JHtml::_('select.option', "$rootId", 'Select an option'); } // Format the items. foreach($items as $item) { // Translate ROOT if ($item->level == 0) { $item->###CODE_TEXT### = JText::_('JGLOBAL_ROOT_PARENT'); } $itemLevelIndicator = str_repeat('- ', !$item->level ? 0 : $item->level - 1); $options[] = JHtml::_('select.option', $item->###ID###, $itemLevelIndicator . $item->###CODE_TEXT###); } return $options; ### Link the fields to your admin view If you haven't already, create your view. Give it names, a short description, and save. Now, go to the "Fields" tab, and click the "Edit" button for "Linked Fields". A new page will open. Add the fields: - **Parent Id** - Select "Only in Linked List Views" from the "Admin behavior" dropdown. Configure to taste. - **Title** - Select "Show in All List Views" from the "Admin behavior" dropdown, and check Title. Configure to taste. - **Alias** - JCB comes with a good alias field; just use that. Select "Show in All List Views" and check "Alias". Configure to taste. - **Lft, Rgt, Level, and Path** - Leave default settings. Save and close. ### Add admin fields relations to the view In the list view, we're going to want to display the nesting of the fields. The usual way Joomla does this is by adding characters like '–' before the title link to show how deeply it's nested. That's what we're going to set up now. In the 'Fields' tab, there's a button that says "Edit admin fields relations for this admin view". Click on that. Set "List Field" to your title field. Then, for the Join field select your level field. Set "Join Type" to Custom Code, and in the Set field paste this code, replacing LEVEL and TITLE with the codes that JCB generates for you. // Indicate how deeply nested the item is. $leading = ''; if ($item->{LEVEL} > 2) $leading .= str_repeat('┊ ', $item->{LEVEL} - 2); if ($item->{LEVEL} > 1) $leading .= '– '; $item->{TITLE} = $leading . $item->{TITLE}; Save and close. ### Add custom code to the admin view We're almost done in the view! Click the PHP tab and add this code to the following sections: #### PHP getListQuery Method // Exclude the root node. $query->where($db->quoteName('a.level') . " > 0"); #### PHP getForm Method // HACK: Including these items in the form interferes with the table's processing. $form->removeField('lft'); $form->removeField('rgt'); $form->removeField('level'); $form->removeField('path'); That's it. Save and close. ### Modify the table Before we can continue, we need to compile. If you haven't already, add the view to your component, and compile. It will not be functional yet. Now, open up your IDE and navigate to administrator/components/com_yourcomponent/tables/yourview.php. #### Inherit from JTableNested JTableNested is where all of the magic happens. It does like 95% of the work for us. If you want to dig around in the code, go to libraries/src/Table/Nested.php in your joomla installation. Okay let's get to the code. Remove the line at the top that looks like: class YourcomponentTableYourview extends JTable ... and replace it with: /***[REPLACE<>$$$$]***/ class YourcomponentTableYourview extends JTableNested /***[/REPLACE<>$$$$]***/ *Make sure you're not just straight up copy pasting, as your class name will be different than this!* #### Modify the __construct() function After the line: parent::__construct('#__efsearch_search_item', 'id', $db); ... insert the following snippet: /***[INSERT<>$$$$]***/ $this->setColumnAlias('ordering', 'lft'); /***[/INSERT<>$$$$]***/ This is required to get ordering in the list view working properly. Note that this is a bit of a HACK: normally you wouldn't include an ordering column when using nested sets, but JCB adds it automatically. #### Add table functions Somewhere in the class, add these functions. I dropped them in under the `__construct` function. We'll be using them elsewhere in the code. *Make sure you replace the #_yourcomponent_yourview table name with one that fits your project.* /***[INSERT<>$$$$]***/ // Insert a root item. Source: https://docs.joomla.org/Using_nested_sets public function addRoot() { $db = JFactory::getDbo(); $query = $db->getQuery(true) ->insert('#_yourcomponent_yourview') ->set($db->quoteName('parent_id') . ' = 0') ->set($db->quoteName('lft') . ' = 0') ->set($db->quoteName('rgt') . ' = 1') ->set($db->quoteName('level') . ' = 0') ->set($db->quoteName('title') . ' = ' . $db->quote('root')) ->set($db->quoteName('alias') . ' = ' . $db->quote('root')) ->set($db->quoteName('access') . ' = 1') ->set($db->quoteName('path') . ' = ' . $db->quote('')); $db->setQuery($query); if(!$db->execute()) { return false; } return $db->insertid(); } // Get the root id (this override adds the root if it doesn't exist) public function getRootId() { $rootId = parent::getRootId(); if ($rootId === false) { $rootId = $this->addRoot(); } return $rootId; } /***[/INSERT<>$$$$]***/ #### Modify the table's store() function JTableNested does most of the work, but we have to manually tell it to add child nodes to their parents, and also help it rebuild the paths afterward. Just so you can see the context, I'm going to paste the entire function. The custom code you need to add is indicated by the custom code tags. *Edit: I replaced 'last-child' with 'first-child' in the calls to moveByReference and setLocation. Depending on your preference, you may want to use one or the other.* public function store($updateNulls = false) { $date = JFactory::getDate(); $user = JFactory::getUser(); if ($this->id) { // Existing item $this->modified = $date->toSql(); $this->modified_by = $user->get('id'); /***[INSERT<>$$$$]***/ // If the parent node has changed, append this item to it. $node = parent::getTree($this->id)[0]; if ($node->parent_id != $this->parent_id) parent::moveByReference($this->parent_id, 'first-child', $this->id); /***[/INSERT<>$$$$]***/ } else { // New yourview. A yourview created and created_by field can be set by the user, // so we don't touch either of these if they are set. if (!(int) $this->created) { $this->created = $date->toSql(); } if (empty($this->created_by)) { $this->created_by = $user->get('id'); } /***[INSERT<>$$$$]***/ // Append this new item to its parent. parent::setLocation($this->parent_id, 'first-child'); /***[/INSERT<>$$$$]***/ } if (isset($this->alias)) { // Verify that the alias is unique $table = JTable::getInstance('yourview', 'Yourcomponent'); if ($table->load(array('alias' => $this->alias)) && ($table->id != $this->id || $this->id == 0)) { $this->setError(JText::_('COM_YOURCOMPONENT_YOURVIEW_ERROR_UNIQUE_ALIAS')); return false; } } if (isset($this->url)) { // Convert IDN urls to punycode $this->url = JStringPunycode::urlToPunycode($this->url); } if (isset($this->website)) { // Convert IDN urls to punycode $this->website = JStringPunycode::urlToPunycode($this->website); } /***[REPLACE<>$$$$]***/ // The below is taken from Joomla's JTableMenu object. (see /libraries/src/Table/Menu.php) if (!parent::store($updateNulls)) { return false; } // Get the new path in case the node was moved $pathNodes = $this->getPath(); $segments = array(); foreach ($pathNodes as $node) { // Don't include root in path if ($node->alias !== 'root') { $segments[] = $node->alias; } } $newPath = trim(implode('/', $segments), ' /\\'); // Use new path for partial rebuild of table // Rebuild will return positive integer on success, false on failure return $this->rebuild($this->{$this->_tbl_key}, $this->lft, $this->level, $newPath) > 0; /***[/REPLACE<>$$$$]***/ } #### Modify the table's check() function We only want to replace one line in this function. At the very end, replace the line: return true; ... with: /***[REPLACE<>$$$$]***/ // Check for a path. Taken from Joomla's JTableMenu object. (see /libraries/src/Table/Menu.php) if (trim($this->path) === '') { $this->path = $this->alias; } return parent::check(); /***[/REPLACE<>$$$$]***/ That's is for the table class! ### Get list view ordering working At this point your view should be working. You can add items, add a parent using the dropdown, and as you do this, JTableNested modified the lft and rgt fields to reflect the new nested order. But in the list view, things are completely out of order, so we want to fix that. Doing so will involve overwriting a few files: #### administrator/components/com_yourcomponent/models/yourview.php Note that this is the model for the single item view, not for the list view. Somewhere in the class, insert the following function: /***[INSERT<>$$$$]***/ /** * Method to save the reordered nested set tree. * First we save the new order values in the lft values of the changed ids. * Then we invoke the table rebuild to implement the new ordering. * * @param array $idArray An array of primary key ids. * @param integer $lftArray The lft value * * @return boolean False on failure or error, True otherwise * * @since 1.6 */ public function saveorder($idArray = null, $lftArray = null) { // Get an instance of the table object. $table = $this->getTable(); if (!$table->saveorder($idArray, $lftArray)) { $this->setError($table->getError()); return false; } // Clear the cache $this->cleanCache(); return true; } /***[/INSERT<>$$$$]***/ It is taken straight out of Joomla's category component. Normally the model handles the saveorder function, but JTableNested has custom handling for it, so we want to forward along to it. #### administrator/components/com_yourcomponent/models/yourviews.php Note that this is the model for your list view, not your item view. You can tell the difference because the list view is plural. In the `getListQuery()` function, find the lines: // Add the list ordering clause. $orderCol = $this->state->get('list.ordering', 'a.id'); $orderDirn = $this->state->get('list.direction', 'desc'); ... and replace them with: /***[REPLACE<>$$$$]***/ // Sort by lft ascending by default: it displays the items in order. $orderCol = $this->state->get('list.ordering', 'a.lft'); $orderDirn = $this->state->get('list.direction', 'asc'); // HACK: rather than completely remove ordering, I'm just overriding its // behavior, but letting JCB think it's sorting by order. if ($orderCol == 'a.ordering') { $orderCol = 'a.lft'; } /***[/REPLACE<>$$$$]***/ #### administrator/components/com_yourcomponent/views/yourviews/view.html.php Again, check that the view folder's name is plural. This is the list view we're modifying. In the `display()` function, find the lines: // Add the list ordering clause. $this->listOrder = $this->escape($this->state->get('list.ordering', 'a.id')); $this->listDirn = $this->escape($this->state->get('list.direction', 'DESC')); ... and replace them with: /***[REPLACE<>$$$$]***/ // Use 'ordering' as the default sort option. Note that in the model this is // overriden and 'lft' is used instead (see HACK comment there) $this->listOrder = $this->escape($this->state->get('list.ordering', 'a.ordering')); $this->listDirn = $this->escape($this->state->get('list.direction', 'ASC')); // Preprocess the list of items to find ordering divisions. (source: com_categories) foreach ($this->items as &$item) { $this->ordering[$item->parent_id][] = $item->id; } /***[/REPLACE<>$$$$]***/ #### administrator/components/com_yourcomponent/views/yourviews/tmpl/default_body.php Modify this template using the custom code snippets shown here: <?php // No direct access to this file defined('_JEXEC') or die('Restricted access'); $edit = "index.php?option=com_yourcategory&view=yourview&task=yourview.edit"; ?> <?php foreach ($this->items as $i => $item): ?> <?php $canCheckin = $this->user->authorise('core.manage', 'com_checkin') || $item->checked_out == $this->user->id || $item->checked_out == 0; $userChkOut = JFactory::getUser($item->checked_out); $canDo = YourcomponentHelper::getActions('yourview',$item,'yourview'); /***[INSERT<>$$$$]***/ // Get the parents of item for sorting. (source: com_categories) if ($item->level > 1) { $parentsStr = ''; $_currentParentId = $item->parent_id; $parentsStr = ' ' . $_currentParentId; for ($i2 = 0; $i2 < $item->level; $i2++) { foreach ($this->ordering as $k => $v) { $v = implode('-', $v); $v = '-' . $v . '-'; if (strpos($v, '-' . $_currentParentId . '-') !== false) { $parentsStr .= ' ' . $k; $_currentParentId = $k; break; } } } } else { $parentsStr = ''; } // Below, include the sort attributes for nested sorting. /***[/INSERT<>$$$$]***/ ?> <!--[REPLACE<>$$$$]--> <tr class="row<?php echo $i % 2; ?>" sortable-group-id="<?php echo $item->parent_id; ?>" item-id="<?php echo $item->id ?>" parents="<?php echo $parentsStr ?>" level="<?php echo $item->level ?>"> <!--[/REPLACE<>$$$$]--> <td class="order nowrap center hidden-phone"> <?php if ($canDo->get('core.edit.state')): ?> <?php $iconClass = ''; if (!$this->saveOrder) { $iconClass = ' inactive tip-top" hasTooltip" title="' . JHtml::tooltipText('JORDERINGDISABLED'); } ?> <span class="sortable-handler<?php echo $iconClass; ?>"> <i class="icon-menu"></i> </span> <?php if ($this->saveOrder) : ?> <!--[REPLACED$$$$]--><!--72--> <input type="text" style="display:none" name="order[]" size="5" value="<?php echo $item->lft; ?>" class="width-20 text-area-order " /> <!--[/REPLACED$$$$]--> <?php endif; ?> . . . #### administrator/components/com_yourcomponent/views/yourviews/tmpl/default.php Finally, insert the custom code snippet used here. *Make sure to replace 'yourview' so that it matches the item view in your project.* <?php // No direct access to this file defined('_JEXEC') or die('Restricted access'); JHtml::_('behavior.tooltip'); JHtml::_('behavior.multiselect'); JHtml::_('dropdown.init'); JHtml::_('formbehavior.chosen', '.multipleAccessLevels', null, array('placeholder_text_multiple' => '- ' . JText::_('COM_YOURCOMPONENT_FILTER_SELECT_ACCESS') . ' -')); JHtml::_('formbehavior.chosen', 'select'); if ($this->saveOrder) { $saveOrderingUrl = 'index.php?option=com_yourcomponent&task=yourview.saveOrderAjax&tmpl=component'; /***[REPLACE<>$$$$]***/ // Set the final parameter, which is the nestedList option, to 1. This makes // it so when dragging parent items, their children are also moved with them. JHtml::_('sortablelist.sortable', 'yourviewList', 'adminForm', strtolower($this->listDirn), $saveOrderingUrl, '', '1'); /***[/REPLACE<>$$$$]***/ } ?> #### *EDIT May 17 2022*: administrator/components/com_yourcomponent/models/forms/filter_yourviews.xml I missed this one: you also need to make 'ordering' the default ordering option in the filter XML. Without this you occasionally get bugs where items are unexpectedly ordered by id. In this file you want to change the line `default="a.id DESC"` to `default="a.ordering ASC"`. You have to replace the entire node to make this change because XML doesn't allow you to add comments inside of nodes. The end result will look something like: <fields name="list"> <!--[REPLACE<>$$$$]--> <field name="fullordering" type="list" label="COM_CONTENT_LIST_FULL_ORDERING" description="COM_CONTENT_LIST_FULL_ORDERING_DESC" onchange="this.form.submit();" default="a.ordering ASC" validate="options" > <!--[REPLACE<>$$$$]--> <option value="">JGLOBAL_SORT_BY</option> <option value="a.ordering ASC">JGRID_HEADING_ORDERING_ASC</option> <option value="a.ordering DESC">JGRID_HEADING_ORDERING_DESC</option> . . . ### That's it! You did it! Your list view now behaves a lot like the categories view does now, except you can do a lot more interesting things with it. If you notice any issues let me know and I'll update this! ### Gotchas - The title must have the name 'title'. JCB lets people use other names for their title field, so to preserve this flexibility you might have to override the JTableNested class (see libraries/src/Table/Nested.php) - I had to use a few hacks to make things work well with JCB. I've labeled them in comments with HACK. In particular, the ordering hack I used is a bit smelly. It works for me but probably isn't how you'll want to do it.

This looks straightforward really... and brings up another topic, the option to change what class a (controller,model,view,table...) extends. I am working on a change to JCB that will give this functionality so we can change the classes being extended. This is part of getting ready for Joomla 4.

This looks straightforward really... and brings up another topic, the option to change what class a (controller,model,view,table...) extends. I am working on a change to JCB that will give this functionality so we can change the classes being extended. This is part of getting ready for Joomla 4.
Obscerno commented 2021-05-31 15:53:39 +00:00 (Migrated from github.com)

One thing I didn't think about when I wrote this is exporting/importing items. I tried it just to see how things would go, but it came out a mess. I'm not planning to tackle it right now but I thought I'd mention it!

My first thoughts: the parent relationship is based on other items in the same table, so you could preserve the nesting by storing that relationship in the csv file somehow, but there would need to be import code that interprets it and adds items to their parents. The import code would also need to exclude the 'level', 'path', and 'rgt' fields, and while 'lft' should be referenced to get the insertion order right, it should also be excluded when actually storing the item, because it trips up JTableNested to include any of those four fields in the data.

PS: I think I maybe found a bug. When I exported, the titles in my list view were used, (the ones I set up using JCB's admin fields relations), not the actual item's titles. So for example, one item called "Child Item" might instead say "- Child Item" with a dash at the start. That extra dash then becomes part of the title on import.

One thing I didn't think about when I wrote this is exporting/importing items. I tried it just to see how things would go, but it came out a mess. I'm not planning to tackle it right now but I thought I'd mention it! My first thoughts: the parent relationship is based on other items in the same table, so you could preserve the nesting by storing that relationship in the csv file somehow, but there would need to be import code that interprets it and adds items to their parents. The import code would also need to exclude the 'level', 'path', and 'rgt' fields, and while 'lft' should be referenced to get the insertion order right, it should also be excluded when actually storing the item, because it trips up JTableNested to include any of those four fields in the data. **PS:** I think I maybe found a bug. When I exported, the titles in my list view were used, (the ones I set up using JCB's admin fields relations), not the actual item's titles. So for example, one item called "Child Item" might instead say "- Child Item" with a dash at the start. That extra dash then becomes part of the title on import.
Obscerno commented 2021-06-04 16:15:32 +00:00 (Migrated from github.com)

I missed a few things for getting list view sorting working properly. I added them above but I'll also list the changes here.

The most important one is that JTableNested has its own function for handling the saveorder() task, so we need to add the following function to the item view (not the list view). This is nabbed straight out of com_categories:

/**
 * Method to save the reordered nested set tree.
 * First we save the new order values in the lft values of the changed ids.
 * Then we invoke the table rebuild to implement the new ordering.
 *
 * @param   array    $idArray   An array of primary key ids.
 * @param   integer  $lftArray  The lft value
 *
 * @return  boolean  False on failure or error, True otherwise
 *
 * @since   1.6
 */
public function saveorder($idArray = null, $lftArray = null)
{
    // Get an instance of the table object.
    $table = $this->getTable();

    if (!$table->saveorder($idArray, $lftArray))
    {
        $this->setError($table->getError());

        return false;
    }

    // Clear the cache
    $this->cleanCache();

    return true;
}

The other two changes are more hacks to work around JCB's default handling of ordering. They may not be relevant to you depending on how you decide to implement this.

In the __construct function in the table for the view, I add this line:

$this->setColumnAlias('ordering', 'lft');

I do this because in JTableNested's rebuild function, it will sort by the 'ordering' column if it exists, rather than the 'lft' column, and this causes issues. You can read the code for it at libraries/src/Table/Nested.php

Finally, you need to use 'lft' as the value for the drag handles on list items, not ordering. So in the default_body.php template I swap in this code:

<input type="text" style="display:none" name="order[]" size="5"
value="<?php echo $item->lft; ?>" class="width-20 text-area-order " />
I missed a few things for getting list view sorting working properly. I added them above but I'll also list the changes here. The most important one is that JTableNested has its own function for handling the saveorder() task, so we need to add the following function to the item view (not the list view). This is nabbed straight out of com_categories: /** * Method to save the reordered nested set tree. * First we save the new order values in the lft values of the changed ids. * Then we invoke the table rebuild to implement the new ordering. * * @param array $idArray An array of primary key ids. * @param integer $lftArray The lft value * * @return boolean False on failure or error, True otherwise * * @since 1.6 */ public function saveorder($idArray = null, $lftArray = null) { // Get an instance of the table object. $table = $this->getTable(); if (!$table->saveorder($idArray, $lftArray)) { $this->setError($table->getError()); return false; } // Clear the cache $this->cleanCache(); return true; } The other two changes are more hacks to work around JCB's default handling of ordering. They may not be relevant to you depending on how you decide to implement this. In the __construct function in the table for the view, I add this line: $this->setColumnAlias('ordering', 'lft'); I do this because in JTableNested's rebuild function, it will sort by the 'ordering' column if it exists, rather than the 'lft' column, and this causes issues. You can read the code for it at libraries/src/Table/Nested.php Finally, you need to use 'lft' as the value for the drag handles on list items, not ordering. So in the default_body.php template I swap in this code: <input type="text" style="display:none" name="order[]" size="5" value="<?php echo $item->lft; ?>" class="width-20 text-area-order " />
Llewellyn removed the
enhancement
label 2022-07-11 14:00:30 +00:00
Llewellyn added this to the Feature Requests project 2022-08-05 08:33:57 +00:00
Llewellyn added the
enhancement
label 2022-08-05 08:34:07 +00:00
Sign in to join this conversation.
No Assignees
1 Participants
Notifications
Due Date
The due date is invalid or out of range. Please use the format 'yyyy-mm-dd'.

No due date set.

Dependencies

No dependencies set.

Reference: joomla/Component-Builder#752
No description provided.