Nested Categories in a Template

dazglaser posted on the Perch Community forum asking how to output multiple levels of Categories in a template where each level should be inside <ul>. So the markup would be nested <ul>s similar to this:

<ul>
    <li>level 1 - A</li>
    <li>level 1 - B</li>
    <li>
        level 1 - C
        <ul>
            <li>level 2 - A</li>
            <li>
                level 2 - B
                <ul>
                    <li>level 3 - A</li>
                    <li>
                        level 3 - B
                        <ul>
                            <li>level 4 - A</li>
                            <li>level 4 - B</li>
                        </ul>
                    </li>
                </ul>
            </li>
        </ul>
    </li>
</ul>

He had a solution that ouput 2 nested levels, but he needed to nest up to 4 levels.

The issue

The issue is perch_categories() returns a flat list of your categories. So your template normally outputs one <ul> for all categories regardless of their levels:

<ul>
    <li>level 1 - A</li>
    <li>level 1 - B</li>
    <li>level 1 - C</li>
    <li>level 2 - A</li>
    <li>level 2 - B</li>
    <li>level 3 - A</li>
    <li>level 3 - B</li>
    <li>level 4 - A</li>
    <li>level 4 - B</li>
</ul>

You can add classes to <li> based on their level with conditional tags, but this doesn’t give us the markup we’re looking for. The nested <ul>s are particularly helpful if you have a categories-based navigational menu.

The first solution that you may think of is to output the openning/closing <ul> and <li> tags conditionally in the template, which is not impossible, but may result in a template filled with conditional tags and that may be not easy to maintain.

The 2-level solution

Going through the 2-level solution will make it easier to digest the dynamic solution.

When using perch_categories(), get the parent (first-level) categories only:

perch_categories([
    'template' => 'list.html',
    'set' => 'my-set',
    'filter' => 'catParentID',
    'match' => 'eq',
    'value' => 0
]);

Make use of the each option to get the sub-categories of each parent category:

perch_categories([
    'template' => 'list.html',
    'set' => 'my-set',
    'filter' => 'catParentID',
    'match' => 'eq',
    'value' => 0,
    'each' => function($item) {
        $item['subitems'] = perch_categories([
            'template' => 'list.html',
            'filter' => 'catParentID',
            'match' => 'eq',
            'value' => $item['catID'],
        ], true);

        return $item;
    }
]);

What we are doing here is getting the sub-categories and returning the templated list. Now $item['subitems'] is a templated list of the sub-categories:

$item['subitems'] = perch_categories([
    'template' => 'list.html',
    'filter' => 'catParentID',
    'match' => 'eq',
    'value' => $item['catID'],
], true);

This way we can output sub-categories for each first-level category with the ID subitems:

<perch:before>
    <ul>
</perch:before>
        <li>
            <perch:category id="catTitle">
            <perch:category id="subitems" html>
        </li>
<perch:after>
    </ul>
</perch:after>

Note the use of the html attribute on the subitems tag. This is because subitems is a templated list that contains some HTML tags. This is so Perch doesn’t encode the HTML tags.

The dynamic solution

The perch_categories() call inside the each option can also include have an each option to fetch sub-categories:

perch_categories([
    'template' => 'list.html',
    'set' => 'my-set',
    'filter' => 'catParentID',
    'match' => 'eq',
    'value' => 0,
    'each' => function($item) {
        $item['subitems'] = perch_categories([
            'template' => 'list.html',
            'filter' => 'catParentID',
            'match' => 'eq',
            'value' => $item['catID'],
            'each' => function($item) {
                $item['subitems'] = perch_categories([
                    'template' => 'list.html',
                    'filter' => 'catParentID',
                    'match' => 'eq',
                    'value' => $item['catID'],
                ], true);

                return $item;
            }
        ], true);

        return $item;
    }
]);

I think you can agree that hardcoding it this way is not very practical especially that we want at least 4 nested levels. Besides, if you wanted to include more levels, you’d need to hardcode another nested each option. It will get messy very quickly.

So to make this solution more dynamic, we can use a custom function in place of the perch_categories() call inside the each option:

perch_categories([
    'set' => 'products',
    'filter' => 'catParentID',
    'match' => 'eq',
    'value' => 0,
    'each' => function($item) {
        $item['level'] = 1;
        $item['subitems'] = get_sub_categories($item);
        return $item;
    }
]); 

The function get_sub_categories() is to dynamically get the sub-categories of each category:

function get_sub_categories($item) {
    $subitems = perch_categories([
        'template' => 'list.html',
        'filter' => 'catParentID',
        'match' => 'eq',
        'value' => $item['catID'],
        'each' => function($item) {
            // get subitems of each, if any exists
            $item['level'] = substr_count($item['catTreePosition'], '-');
            $item['subitems'] = get_sub_categories($item);
            return $item;
        }
    ], true);

    return $subitems;
} 

Inside the function we call perch_categories() like we did in the 2-level solution. We also make use of the each option here and we call get_sub_categories(). This is so it can dynamically get the sub-categories of each category without us having to hardcode anything. Once there aren’t any more sub-categories, the function will stop calling itself.

I’ve also included level for each category. This allows you to use it in conditional tags in case you want to apply different CSS classes or include different markup for some levels:

<perch:if id="level" match="eq" value="3">
<!--* do something on the 3rd level only *-->
</perch:if>
link