Best way to create filters like Newegg or B&H Photo

The extra Taxonomies has the snippet getPagesByTerm that returns the resources for one term only (property $term_id). If you want to filter by multiple terms, you have to write your own snippet (or extend the snippet getPagesByTerm). You also have to process $_POST or $_GET parameters from the frontend yourself. Additionally it seems that getPagesByTerm doesn’t support template variables.

Maybe you can use the extra Tagger instead for your purposes (if you don’t need hierarchically nested tags).

1 Like

I don’t really need the terms to be hierarchical, but I do need them to be in groups and preferably be predefined in the back end where the creators can check the matching terms rather than make things up on the fly. I liked the discoverability of terms in the backend with Taxonomies. If someone creates a new term in the back end it also needs to update the front end collection of checkboxes.

Most e-commerce stores use “faceted search”, which is what you have shown in your example photos. This differs from standard filtering by the ability to offer further arrays of filters depending on previous choices. If this is the complexity you’re needing then I think you will need to look at one of the MODX search extras like SimpleSearch or AdvSearch, combined with Tagger (probably). If you do a search for posts on those extras you will find some discussions about this already. And the MODX docs have a short article on SimpleSearch faceted search here:


If you don’t need the faceted aspect and just want a list of grouped filters, you can use Tagger to do this.

I had seen the faceted search example, because I have SimpleSearch setup, however (unless I missed something) I ruled that out because it looks like you have to setup a new PostHook for every filter term you add. If a manager wants to add a new term every coulple weeks while building out the site that becomes too complicated.

On the backend at least, Taxonomies seems to work perfectly like they want because a manager can just go create a new taxonomy term in the resource tree and it automatically shows up on every resource for any creator to checkbox that term.

The problem of course has been getting those same terms to be checkbox filters on the front end for anyone searching for things. And preferably to have it automatic whenever a term is added to the tree. I figured getResources could probably handle that, eventually.

I’m about to take another look at Tagger again to see if it can be clamped down a little to act similarly to Taxomomies

Not positive, but I believe with any of the tagging extras you will only be able to match one term at a time. So, if you want to be able to allow multiple checked filters then you will need one of the search extras… I think you can get Tagger and AdvSearch working together (I saw an article somewhere), so that might allow the backend ease you want with the frontend requirements. Maybe. Hopefully someone who has implemented this will pop in to help.

With the extra Tagger you can also search for multiple tags. The problem is, that with TaggerGetResourcesWhere you either get the resources that match at least one of the specified tags or the resources that match all of them ( &matchAll=`1` ). But what you probably want, is a behaviour like the one described in this question:

To achieve that, you’ll have to write your own snippet.

There’s a way, using Tagger, to search for multiple tags from the frontend? As in, render checkboxes that the user can check one or many?

Here is a simple example with Tagger that uses a custom snippet (as a replacement for TaggerGetResourcesWhere) to allow for the mixing of AND and OR (as described in my last post).

I created two groups in Tagger:

  • Group “color” (Id=1) with the tags “red”, “green” and “blue”
  • Group “size” (Id=2) with the tags “small”, “medium” and “large”

Template

[[!SetSubquery]]

<form method="post" action="[[~[[*id]]]]" id="my_filter_form">
    <h3>Color</h3>
    [[TaggerGetTags? &groups=`1` &rowTpl=`tplTagCheckbox`]]
    <h3>Size</h3>
    [[TaggerGetTags? &groups=`2` &rowTpl=`tplTagCheckbox`]]
    <input type="submit" value="Filter data">
</form>
<hr>
[[!getResources? &where=`[[!+filter_subquery]]`
    &parents=`1`
    &depth=`0`
    &limit=`0`
    &tpl=`@INLINE <h2>[[+pagetitle]] ([[+id]])</h2>`
]]

Set the property &parents to the right value.

Chunk "tplTagCheckbox"

<label for="filter-tag-[[+id]]"><input type="checkbox" id="filter-tag-[[+id]]" name="filter[[+group_id]][]" value="[[+id]]" [[!+filter_tag_ids:FormItIsChecked=`[[+id]]`]]/>[[+tag]]</label>

This chunk uses the snippet FormItIsChecked from the extra FormIt.

Snippet "SetSubquery"

<?php
$tagger = $modx->getService('tagger','Tagger',$modx->getOption('tagger.core_path',null,$modx->getOption('core_path').'components/tagger/').'model/tagger/',$scriptProperties);
if (!($tagger instanceof Tagger)) return '';

$group_ids = array(1,2); //The groups to include in the filtering
$all_checked_tags = array();
$where = array();
foreach($group_ids as $group_id){
    if (isset($_POST['filter'.$group_id])) {
        $tag_ids = $_POST['filter'.$group_id];
        if (is_array($tag_ids)){
            $tag_ids = array_map('intval', $tag_ids);
            $where[] = "EXISTS (SELECT 1 FROM {$modx->getTableName('TaggerTagResource')} r WHERE r.tag IN (" . implode(',',$tag_ids) . ") AND r.resource = modResource.id)";
            $all_checked_tags = array_merge($all_checked_tags,$tag_ids);
        }
    }
}
//This placeholder is used to preserve the "checked"-state of the checkboxes
$modx->setPlaceholder('filter_tag_ids',$modx->toJson($all_checked_tags));
//This placeholder is used for the getResources subquery
$modx->setPlaceholder('filter_subquery',$modx->toJSON($where));
return '';

This example uses the id of the tags instead of the alias to keep the code simple.

Similar code could be used for the extra Taxonomies. That extra has the database table tax_page_terms that it basically the same as the table modx_tagger_tag_resources from Tagger.

2 Likes

Is there a way to integrate this with SimpleSearch or alternatively use GetResources (or pdoResources) as a replacement for SimpleSearch so that a manual text search can then be filtered by related tags.

The goal being if you did a manual search first for “shoes”, you then get an adjusted sidebar with related tag groups for additional filtering.
TYPE: boots, tennis shoes, sandals and
ACTIVITY: hiking, running, dancing, lounging, etc.
COLOR: white/gray/black, etc.

Of if someone searched for “shirt”, they might get different tag groups to filter with such as
TYPE: long sleeve, short sleeve, button up, etc.
MATERIAL: cotton/polyester/lycra, etc.
COLOR: blue/red/gray/white/balck, etc.

I can’t quite follow the logic in “SetSubquery” to understand what the final &where looks like, but would it be difficult to add a second array inside SetSubquery to accept data from a regular input “text” field and combine that with all the other tag names?

It seems like GetResources should be able to use that to do regular search engine tasks as long as the appropriate TVs were turned on. Unless I’m not understanding what the &where tag is doing.

It should be possible to add this input field to the form

<input type="text" name="search" value="[[!+search_value]]">

and then add this code to the snippet SetSubquery

$search_value = '';
if (isset($_POST['search'])) {
    $search_value = filter_var($_POST['search'], FILTER_SANITIZE_STRING);
    $min_length_search_value = 3;
    if (mb_strlen($search_value) >= $min_length_search_value){
        $search_value_wildcards = "%" . $search_value . "%";
        $where[] = array("pagetitle:LIKE" => $search_value_wildcards, "OR:content:LIKE" => $search_value_wildcards);
    }
}
$modx->setPlaceholder('search_value', $search_value);

to search in resource fields (pagetitle and content in this example).

For TVs you probably have to use the property &tvFilters (or write your own subquery with "EXISTS ( ...)")


You could add the property &debug to the getResources call.

[[!getResources?
    ...
    &debug=`1`
]]

The SQL query then gets written to the error log and you can check how the value of the where property is converted to the WHERE-clause of the SQL query.

1 Like

That sound like it would be statically built. Can the EXISTS subquery be automatically built based on the TVs listed in the snippet call? Such as:

[[!getResources?
    ...
    &includeTVs=`author,date,image`
]]

But, I also imagine skipping over Image type TVs might be good as well unless the searching system does that automatically.

I noticed something else (bug like) just now.

If I use this with pdoPage (or getPage) for pagination of the output, then as soon as another page is clicked on the filters or text search box are cleared. Page 2 and beyond are just pagination of the full list.

You have to change the form method to “get”

<form method="get" ... >

and change $_POST in the snippet SetSubquery to $_GET so that all the request-parameters get appended to the link for the next page.

1 Like

Only if you change the code of the getResources snippet.


It turns out that the &tvFilters property doesn’t work, unless you only want to search in TVs (and not in form fields as well).

So I believe you have to write a complicated where clause like this to make it work.
And then add OR EXISTS (...) for any additional TV.

$search_value = '';
if (isset($_GET['search'])) {
    $search_value = filter_var($_GET['search'], FILTER_SANITIZE_STRING);
    $min_length_search_value = 3;
    if (mb_strlen($search_value) >= $min_length_search_value){
        $search_value_wildcards = "%" . $search_value . "%";
        //searches in pagetitle, content and the author TV.
        $where[] = "(`modResource`.`pagetitle` LIKE '" . $search_value_wildcards . "' OR `modResource`.`content` LIKE '" . $search_value_wildcards . "' OR EXISTS (SELECT 1 FROM `modx_site_tmplvar_contentvalues` tvr JOIN `modx_site_tmplvars` tv ON tvr.value LIKE '" . $search_value_wildcards . "' AND tv.name = 'author' AND tv.id = tvr.tmplvarid WHERE tvr.contentid = modResource.id))";
    }
}
$modx->setPlaceholder('search_value', $search_value);

There is a paid search extra mSearch2 that has a built-in filter functionality (snippet mFilter2).
I never used it myself, but it’s likely a cleaner solution than using getResources.


Also the extra AdvSearch has a property queryHook, that probably can be used to query the tagger tags.

Although still hard coded into SetSubquery, it looks like when pdoPage or pdoResources have the TVs initialized like so:

[[!pdoPage?
    ...
    &includeTVs=`author,subtitle,series,date,image`
    &processTVs=`1`
]]

(which I needed for proper display of the output)

Then the TVs are searchable just like any resource field. I added them to the end of the where line and it seems to work fine.

$search_value = '';
if (isset($_POST['search'])) {
    $search_value = filter_var($_POST['search'], FILTER_SANITIZE_STRING);
    $min_length_search_value = 3;
    if (mb_strlen($search_value) >= $min_length_search_value){
        $search_value_wildcards = "%" . $search_value . "%";
        $where[] = array("pagetitle:LIKE" => $search_value_wildcards, "OR:author:LIKE" => $search_value_wildcards, "OR:subtitle:LIKE" => $search_value_wildcards, "OR:series:LIKE" => $search_value_wildcards);
    }
}
$modx->setPlaceholder('search_value', $search_value);

It seems that after upgrading to MODx 3.0.1 this doesn’t search anymore. Simplesearch was also broken, but was fixed by updating the search driver URL.

Any idea why this might have broken?

The browser URL does update when a search or filter is applied, but the results don’t update.

This is the final code of the snippet.

<?php
$tagger = $modx->getService('tagger','Tagger',$modx->getOption('tagger.core_path',null,$modx->getOption('core_path').'components/tagger/').'model/tagger/',$scriptProperties);
if (!($tagger instanceof Tagger)) return '';

//This section searches based on Tagger words
$group_ids = array(1,2,3,4,5); //The groups to include in the filtering
$all_checked_tags = array();
$where = array();
foreach($group_ids as $group_id){
    if (isset($_GET['filter'.$group_id])) {
        $tag_ids = $_GET['filter'.$group_id];
        if (is_array($tag_ids)){
            $tag_ids = array_map('intval', $tag_ids);
            $where[] = "EXISTS (SELECT 1 FROM {$modx->getTableName('TaggerTagResource')} r WHERE r.tag IN (" . implode(',',$tag_ids) . ") AND r.resource = modResource.id)";
            $all_checked_tags = array_merge($all_checked_tags,$tag_ids);
        }
    }
}
//This section performs a regular text search
$search_value = '';
if (isset($_GET['search'])) {
    $search_value = filter_var($_GET['search'], FILTER_SANITIZE_STRING);
    $min_length_search_value = 3;
    if (mb_strlen($search_value) >= $min_length_search_value){
        $search_value_wildcards = "%" . $search_value . "%";
        $where[] = array("pagetitle:LIKE" => $search_value_wildcards, "OR:tv_title:LIKE" => $search_value_wildcards, "OR:tv_subtitle:LIKE" => $search_value_wildcards, "OR:tv_description:LIKE" => $search_value_wildcards, "OR:tv_author:LIKE" => $search_value_wildcards, "OR:tv_reference:LIKE" => $search_value_wildcards);
    }
}
$modx->setPlaceholder('search_value', $search_value);

//This placeholder is used to preserve the "checked"-state of the checkboxes
$modx->setPlaceholder('filter_tag_ids',$modx->toJson($all_checked_tags));
//This placeholder is used for the getResources subquery
$modx->setPlaceholder('filter_subquery',$modx->toJSON($where));
return '';

I believe in MODx 3, Tagger has also been updated in someway although I don’t know if that would affect the function of the search.

Tagger 2.0.0

  • Add support for Revolution 3.0.0
  • Remove group placement above/under content

Namespaces have been added, so you have to adapt your code accordingly:

if (!($tagger instanceof Tagger\Tagger)) return '';
...
... $modx->getTableName('Tagger\\Model\\TaggerTagResource') ...

Also $modx->getService() is deprecated and the line should probably be replaced with something like this:

$tagger = null;
try {
    if ($modx->services->has('tagger')) {
        $tagger = $modx->services->get('tagger');
    }
} catch (ContainerExceptionInterface $e) {
    return '';
}
1 Like