Grouping items in a template

There was a question on Perch forum about grouping list items by the value of a field. Specifically they wanted to group items by the first letter of the item’s name. So the desired HTML would be something like this:

<section>
    <h2>A</h2>
    <ul>
        <li>Alex...</li>
        <li>Amelia...</li>
    </ul>

    <h2>B</h2>
    <ul>
        <li>Ben...</li>
        <li>Ben...</li>
        <li>Bryan...</li>
    </ul>

    <h2>C</h2>
    <ul>
        <li>Chris...</li>
    </ul>
    .
    .
</section>

For context, let’s say we have a Collection or a multiple-item Region for a company’s employees:

<perch:content id="name" type="text" label="Name" title>
<perch:content id="role" type="text" label="Role">
perch_collection('Team');

You can sort items alphabetically with the sort options. The issue is grouping the items alphabetically.

It is not practical to make 26 database queries (one for each letter). You would make a query to get a list of items which have name values that start with a, then another for those that start with b, and so on.

Instead we can achieve this with a single perch_collection() call and some Perch templating magic.

Sort

The first step is to sort the items by the field you want. We’ll use the name field from our example:

perch_collection('Team', [
    'sort' => 'name',
    'sort-type' => 'alpha',
    'sort-order' => 'ASC',
]);

This sorts the items by name from A to Z.

Template

A basic list template:

<perch:before>
    <h1>Our Team</h1>

    <ul>
</perch:before>

    <li>
        <perch:content id="name" type="text">
    </li>

<perch:after>
    </ul>
</perch:after>

The above outputs all the item in a single list <ul>:

<ul>
    <li>Alexander</li>
    <li>Amelia</li>
    <li>Ben</li>
    <li>Ben</li>
    <li>Bryan</li>
    <li>Chris</li>
</ul>

Now we have a number of challenges:

  1. Dynamically output the first letter of name
  2. Conditionally perform (1) when name is the first occurrence that starts with this letter
  3. Conditionally open a <ul> tag when name is the first occurrence that starts with this letter
  4. Conditionally close </ul> tag when name is the last occurence that starts with this letter

So when we “start a new group”, we perform 1-3. When we “end a group” we perform 4.

Output the first letter

We can use the format attribute to limit the number of characters we output format="C:length":

<perch:content id="name" type="text" format="C:1">

This can also be achieved with the chars attribute:

<perch:content id="name" type="text" chars="1">

While the chars attribute is perhaps easier to read, it is important to understand the syntax of the format attribute as we’ll need use it later in some conditional tags.

Comparing items

We need to conditionally start and end groups (open/close <ul>). In order to do this, we need to compare the current item we are rendering with the previous item.

Perch template engine renders what’s inside perch:before first, then renders one item at a time and finally it renders what’s inside perch:after. We want to perform the comparison in the phase in which it renders one item at a time.

This is where we use Perch conditional tags. perch:if allows us to compare a field value from the current item to the equivalent field from the previous item using the different attribute. To check whether the value of the name field of the current item is the same as the previous item’s:

<perch:if different="name">
    Different
<perch:else>
    Same
</perch:if>

Let’s add this to the basic list template we started with:

<perch:before>
    <h1>Our Team</h1>

    <ul>
</perch:before>

    <li>
        <perch:content id="name" type="text">

        <perch:if different="name">
            (Different from above)
        <perch:else>
            (Same as above)
        </perch:if>
    </li>

<perch:after>
    </ul>
</perch:after>

The output would be:

<ul>
    <li>Alexander (Different from above)</li>
    <li>Amelia (Different from above)</li>
    <li>Ben (Different from above)</li>
    <li>Ben (Same as above)</li>
    <li>Bryan (Different from above)</li>
    <li>Chris (Different from above)</li>
</ul>

This is cool, but in our context we need to compare the first letter of the name field value rather than the whole string. We can perform this comparison by using the format and format-both attributes on the perch:if tag:

<perch:if different="name" format="C:1" format-both>
    Different first letter
<perch:else>
    Same first letter
</perch:if>

The format attribute here behaves the same way as when used on a normal tag. The format-both attribute specifies whether the formatting should be applied to both the current and previous items’ values.

We can also tell Perch to make the comparison case insensitive by using the case attribute:

<perch:if different="name" format="C:1" format-both case="insensitive">
    Different first letter
<perch:else>
    Same first letter
</perch:if>

Applying this to our basic list template:

<perch:before>
    <h1>Our Team</h1>

    <ul>
</perch:before>

    <li>
        <perch:content id="name" type="text">

        <perch:if different="name" format="C:1" format-both case="insensitive">
            (Different first letter from above)
        <perch:else>
            (Same first letter as above)
        </perch:if>
    </li>

<perch:after>
    </ul>
</perch:after>

The output would be:

<ul>
    <li>Alexander (Different first letter from above)</li>
    <li>Amelia (Same first letter as above)</li>
    <li>Ben (Different first letter from above)</li>
    <li>Ben (Same first letter as above)</li>
    <li>Bryan (Same first letter as above)</li>
    <li>Chris (Different first letter from above)</li>
</ul>

Conditionally start and close groups

We can use the same perch:if tag to determine when to start a new group:

<perch:before>
    <h1>Our Team</h1>
</perch:before>

    <perch:if different="name" format="C:1" format-both case="insensitive">
        <!--* Start a group *-->
        <h2>
            <perch:content id="name" type="text" format="C:1">
        </h2>

        <ul>
    </perch:if>

    <!--* Output the item *-->
    <li>
        <perch:content id="name" type="text">
    </li>

Note how in the previous example, the first item evaluates to different. This means the above template will start a new group when rendering the first item too.

Everytime we start a new group it means we are done with the previous group. So we can close group inside the same conditional tags:

<perch:before>
    <h1>Our Team</h1>
</perch:before>

    <perch:if different="name" format="C:1" format-both case="insensitive">
        <!--* close previous group *-->
        </ul>

        <!--* Start a group *-->
        <h2>
            <perch:content id="name" type="text" format="C:1">
        </h2>

        <ul>
    </perch:if>

    <!--* Output the item *-->
    <li>
        <perch:content id="name" type="text">
    </li>

<perch:after>
</perch:after>

However, the above adds a closing tag </ul> before starting the first group. Let’s add another conditional tag to ignore the first item:

<perch:before>
    <h1>Our Team</h1>
</perch:before>

    <perch:if different="name" format="C:1" format-both case="insensitive">
        <!--* close previous group, ignore first item *-->
        <perch:if not-exists="perch_item_first">
            </ul>
        </perch:if>

        <!--* Start a group *-->
        <h2>
            <perch:content id="name" type="text" format="C:1">
        </h2>

        <ul>
    </perch:if>

    <!--* Output the item *-->
    <li>
        <perch:content id="name" type="text">
    </li>

<perch:after>
</perch:after>

If it seems odd to you to add the closing tag </ul> before the opening <ul> and before the <li> tags, remember the different attribute is used to compare against the previous item.

This brings me to the last point. Because we only output the closing tag </ul> when we are starting a new group, we never actually close the last group. That’s why we need to add another closing tag inside perch:after:

<perch:before>
    <h1>Our Team</h1>
</perch:before>

    <perch:if different="name" format="C:1" format-both case="insensitive">
        <!--* Close previous group, ignore first item *-->
        <perch:if not-exists="perch_item_first">
            </ul>
        </perch:if>

        <!--* Start a new group *-->
        <h2>
            <perch:content id="name" type="text" format="C:1">
        </h2>

        <ul>
    </perch:if>

    <!--* Output the item *-->
    <li>
        <perch:content id="name" type="text">
    </li>

<perch:after>
    <!--* Close the last group *-->
    </ul>
</perch:after>
link

Related articles