Customization for searching empty field in Magento 2 Admin Grid

As illustrated in the concept image, we got a request from our client that he wants to use the built-in Admin Grid filter feature to search entries that has empty field. To be more specific in his situation, the "Delivery Date" field is an optional input during the checkout process and the client is looking for a way to only list orders that has this field empty. After some investigation, we discovered that a small customization is required achieve the goal. Let's see how to do this.

Analysis

GUI Limitation

First, you cannot input an empty string and apply the filter because the filter component treats empty input as no filtering on that field, so we must input something. Finally, after talking with the client, we decide to use "" and '' to represent empty string.

Database

Then let's think the database part. Depending on the database schema and model implementation, the delivery_date field may be NULL or an empty string. So we need to modify the final SQL so that it can match both cases.

Strategy

\Magento\Framework\Api\Filter

Now we need to identify how the filter is generated.

(Magento 2.4.7-p3) Input.php#L84

As you can see in the above source file, the text input always has a LIKE condition type, and the value becomes %value%.
So our customization is to modify this filter's value and condition type.

BTW, there's bug on Line 77 (Magento 2.4.7-p3).

Where to Intercept ?

Another important thing is that we need to make this customization specific to the target dataSource. In other words, this customization should not affect other Admin Grids.

Finally, we decide to "after plugin" the DataProvider's getSearchCriteria method as the request name is also set within this method.

Customization

Code was tested on Magento 2.4.x

Identify the correct DataProvider

({{something}} is placeholder)
Open the model listing component, usually, it is view/adminhtml/ui_component/{{model}}_listing.xml.
Note down the dataProvider element's name and class:

<dataProvider name="{{model}}_listing_data_source" class="{{DataProvider}}">
    <settings>
        ...
    </settings>
</dataProvider>

Typically, {{DataProvider}} class is Magento\Framework\View\Element\UiComponent\DataProvider\DataProvider, but it may be different in your case. It just needs to be class that implements Magento\Framework\View\Element\UiComponent\DataProvider\DataProviderInterface, so identify the correct class.
The {{DataProvider}} can also be a virtualType. If so, you need to plugin the real one.

Build the Plugin with di.xml configuration

Below is partial di.xml configuration. Note we set the requestName argument to limit the plugin only working for the dataSource with that name.

<!-- etc/di.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    ...
    <type name="{{DataProvider}}">
        <plugin name="wubinworks_tutorial_plugin_dataprovider_emptyfield" type="Wubinworks\Tutorial\Plugin\DataProvider\EmptyField" />
    </type>
    <type name="Wubinworks\Tutorial\Plugin\DataProvider\EmptyField">
        <arguments>
            <argument name="requestName" xsi:type="string">{{model}}_listing_data_source</argument>
        </arguments>
    </type>
    ...
</config>

Base abstract class for generic procedures:

<?php
/**
 * Copyright © Wubinworks. All rights reserved.
 *
 * NOTICE OF LICENSE
 *
 * This source file is subject to the Open Software License (OSL-3.0)
 * that is available through the world-wide-web at this URL:
 * https://opensource.org/licenses/OSL-3.0
 *
 * @author Wubinworks Dev Team
 * @copyright Wubinworks
 * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL-3.0)
 * @link https://www.wubinworks.com/contact Contact us
 */
declare(strict_types=1);

namespace Wubinworks\Tutorial\Plugin\DataProvider;

use Magento\Framework\View\Element\UiComponent\DataProvider\DataProviderInterface;
use Magento\Framework\Api\Search\FilterGroup;
use Magento\Framework\Api\Search\SearchCriteria;
use Magento\Framework\Api\Search\SearchCriteriaBuilder;
use Magento\Framework\Api\FilterBuilder;

/**
 * Abstract search criteria modifier
 */
abstract class AbstractModifier
{
    public const FIELDS = [];
    public const VALUES = [];
    public const CONDITION_TYPES = [];

    /**
     * @var string
     */
    protected $requestName;

    /**
     * @var FilterBuilder
     */
    protected $filterBuilder;

    /**
     * Constructor
     *
     * @param FilterBuilder $filterBuilder
     * @param ?string $requestName
     */
    public function __construct(
        FilterBuilder $filterBuilder,
        ?string $requestName = null
    ) {
        $this->filterBuilder = $filterBuilder;
        $this->requestName = $requestName;
    }

    /**
     * Plugin entry. Modify search criteria
     *
     * @param DataProviderInterface $subject
     * @param SearchCriteria $result
     * @return SearchCriteria
     *
     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
     */
    public function afterGetSearchCriteria(
        DataProviderInterface $subject,
        SearchCriteria $result
    ): SearchCriteria {
        if (!$this->matchRequestName($result)) {
            return $result;
        }

        foreach ($result->getFilterGroups() as $filterGroup) {
            if ($this->matchFilterGroup($filterGroup)) {
                $this->modifyFilterGroup($filterGroup);
            }
        }

        return $result;
    }

    /**
     * Match request name so it only affects specific DataProvider
     *
     * @param SearchCriteria $searchCriteria
     * @return bool
     */
    protected function matchRequestName(SearchCriteria $searchCriteria): bool
    {
        return $searchCriteria->getRequestName() === $this->requestName;
    }

    /**
     * Match filter group. Override if needed
     *
     * @param FilterGroup $filterGroup
     * @return bool
     */
    protected function matchFilterGroup(FilterGroup $filterGroup): bool
    {
        $filters = $filterGroup->getFilters();
        if (count($filters) !== 1) {
            return false;
        }

        $filter = reset($filters);
        if ((empty(static::FIELDS) || in_array($filter->getField(), static::FIELDS))
            && (empty(static::VALUES) || in_array($filter->getValue(), static::VALUES))
            && (empty(static::CONDITION_TYPES) || in_array($filter->getConditionType(), static::CONDITION_TYPES))) {
            return true;
        }

        return false;
    }

    /**
     * Modify filter group. Your implementation
     *
     * @param FilterGroup $filterGroup
     * @return void
     */
    abstract protected function modifyFilterGroup(FilterGroup $filterGroup): void;
}

Plugin that modifies FilterGroup:

<?php
/**
 * Copyright © Wubinworks. All rights reserved.
 *
 * NOTICE OF LICENSE
 *
 * This source file is subject to the Open Software License (OSL-3.0)
 * that is available through the world-wide-web at this URL:
 * https://opensource.org/licenses/OSL-3.0
 *
 * @author Wubinworks Dev Team
 * @copyright Wubinworks
 * @license https://opensource.org/licenses/OSL-3.0 Open Software License (OSL-3.0)
 * @link https://www.wubinworks.com/contact Contact us
 */
declare(strict_types=1);

namespace Wubinworks\Tutorial\Plugin\DataProvider;

use Magento\Framework\Api\Search\FilterGroup;
use Wubinworks\Tutorial\Plugin\DataProvider\AbstractSearchCriteriaModifier;

/**
 * "" and '' can search NULL or empty string
 */
class EmptyField extends AbstractSearchCriteriaModifier
{
    public const FIELDS = [
        'delivery_date'
    ];

    public const VALUES = [
        '%""%',
        "%''%"
    ];

    public const CONDITION_TYPES = [
        'like'
    ];

    /**
     * @inheritdoc
     */
    protected function modifyFilterGroup(FilterGroup $filterGroup): void
    {
        $filters = $filterGroup->getFilters();
        $filter = reset($filters);
        $filters = [];
        // 'IS NULL'
        $filters[] = $this->filterBuilder->setField($filter->getField())
            ->setValue(true)
            ->setConditionType('null')
            ->create();

        // Empty string
        $filters[] = $this->filterBuilder->setField($filter->getField())
            ->setValue('')
            ->setConditionType('eq')
            ->create();

        // One filter becomes two filters now
        $filterGroup->setFilters($filters);
    }
}

Final Thoughts

By extending the AbstractSearchCriteriaModifier class, you can even modify the filter to:

  • Exact match: eq
  • Regular expression match: regexp
  • 'FIND_IN_SET': finset

Check here to get more options and hints.

In next tutorial, we will demonstrate how to use GROUP BY and HAVING clauses in Magento 2 Admin Grid. They are very useful when you want to make a report for model tables.

。⋆*ᴴᴬᴾᴾᵞ ᴺᴱᵂ ᵞᴱᴬᴿ*⋆。