Clean Air for Craft CMS

Usage

Building a filter

A filter is four things: an element type, a source, a set of conditions, and how those conditions combine.

Element type is the row of tabs. Entries, assets, categories, tags and users are in both editions. Addresses, Commerce orders, products and variants, and any other element type on the site are Pro.

Source narrows the type — a section or entry type for entries, a volume for assets, a group for categories, tags and users, a product type for Commerce. All sources searches everything you have permission to see. Addresses have no source, because Craft has no address index at all.

Conditions are a field, an operator, and whatever the operator needs. Add as many as you like.

Match is all conditions or any condition. CP Filters could only ever AND. See Match any below for what OR costs.

Operators that fit the value

Clean Air does not ask what class a field is. It asks what shape its value is, and the operators follow from that — which is why a plain text field, a URL field, an email field, a CKEditor body, a variant SKU and an address line all get the same operators without any of them having been listed anywhere.

Value shapeOperators
Textcontains, doesn't contain, starts with, ends with, is, is not, is one of, is none of, is empty, is not empty
Number / moneyis, is not, greater than, at least, less than, at most, between, empty, not empty
Dateis on, is before, is after, is between, is in the last N days/weeks/months, is in the next N, empty, not empty
Lightswitchis on, is off
Dropdown / radiois, is not, is one of, is none of, empty, not empty
Checkboxes / multi-selectcontains, doesn't contain, empty, not empty
Relationsis assigned, is not assigned, empty, not empty, has at least N, has at most N
Matrixempty, not empty, has at least N, has at most N

An unrecognised field type does not disappear from the menu — it gets the text operators, which work on whatever is in the content column. To place it properly, use additionalFieldTypes in Configuration, or the EVENT_DEFINE_FIELD_DEFINITION event.

Statuses

Filters run with no status constraint by default. Craft's own indexes default to live, which is exactly wrong for a tool you reach for when something has gone missing. Drafts, revisions and trashed elements are each a separate toggle.

Results

Choose the columns, and sort by any of them. Relations render as links and dates as dates, and the export writes the same values.

Every result set has a URL that reproduces it. Link to these results copies it — paste it into a ticket.

Saved filters

Save a filter and give it a name. Keep it private, or share it with everyone who can use Clean Air.

Seeing a shared filter and being able to rewrite it are separate permissions, so the report your team relies on does not quietly change under them.

Pin a filter to put it in the sidebar.

Lite keeps ten saved filters per person. Pro is unlimited and adds sharing.

Exports

CSV in both editions; TSV, JSON and XML in Pro. Exports are streamed in batches, so a 250,000-row export costs about the same memory as a fifty-row one.

Anything over queueExportThreshold goes to the queue and waits for you on the Exports screen. Files are pruned after exportRetentionDays, because an export is a snapshot of real content and often of personal data.

From a template

Pro only.

{% set featured = craft.cleanair.filter('featured-in-stock') %}
{% for product in featured.limit(8).all() %}
    {{ product.title }}
{% endfor %}

craft.cleanair.filter() takes a saved filter's handle and hands back an element query, so everything you already know about element queries still applies.

Or put a Clean Air Filter field on an entry, let an editor pick the filter, and ask the field for its elements:

{% for entry in entry.productList.all(8) %}

The filter's conditions can then change without a deploy.

From the command line

Pro only.

craft cleanair/filters                        # list saved filters
craft cleanair/filters/count stale-drafts     # just the number
craft cleanair/filters/run stale-drafts       # run one and print what it matched
craft cleanair/filters/export stale-drafts --format=csv --path=/tmp/report.csv
craft cleanair/exports/prune                  # housekeeping

Which is how a saved filter becomes a scheduled report.

Importing from CP Filters

Both editions.

Clean Air reads CP Filters' cpfilters_savedfilters table directly. The old plugin does not need to be installed, or even installable — which matters, because it cannot be installed on Craft 5.

Go to Clean Air → Import. Nothing is written until you say so, and the preview tells you, per filter, exactly what it will become and what it lost on the way.

craft cleanair/import          # preview
craft cleanair/import/run      # import

The mapping is mostly one-to-one. Three places it has to think:

  • Sources. CP Filters stored bare IDs. Entry type, volume, group and product type IDs all survive the Craft 4 → 5 upgrade, so those land exactly. One that no longer exists is reported, and the filter runs across every source instead.
  • Ambiguous operators. is greater than meant one thing on a number and another on a date. Clean Air resolves the field first and picks the operator that matches its kind, so a date filter becomes is after rather than a string comparison that matches nothing.
  • Fields that have gone. A condition naming a field that no longer exists is reported against the filter rather than silently dropped — a filter that quietly loses a condition returns more rows than it should, which is the dangerous direction.

CP Filters' settings come across too, from project config or from a config/cpfilters.php file. Its additionalFieldTypes format is understood as-is.

Your CP Filters data is never modified. Import, compare the two, and uninstall the old plugin when you are happy.

Match any

Two parameters on one element query always AND — that is how Craft works, and there is no parameter syntax for OR across different fields. So a match any filter runs each condition as its own query and unions the IDs. It is correct, it works with every field type including relations, and it costs one query per condition.

There is a configurable ceiling (anyMatchIdLimit, 50,000 by default); results past it are truncated and the filter says so rather than pretending. That is why OR is a deliberate mode rather than the default.

Extending it

use justinholtweb\cleanair\services\Types;
use justinholtweb\cleanair\events\RegisterTypesEvent;

Event::on(Types::class, Types::EVENT_REGISTER_TYPES, function(RegisterTypesEvent $event) {
    $event->types['widgets'] = new MyWidgetType();
});

Types::EVENT_REGISTER_TYPES adds or replaces an element type driver. Fields::EVENT_DEFINE_FIELD_DEFINITION fires per custom field, to correct the value shape Clean Air inferred or to hide a field from the builder entirely.