Dynamic Store Configuration Fields

Magento’s store configuration functionality allows developers to quickly and efficiently define config fields for their modules. This efficiency promotes flexible and configurable modules, saving developers and merchants time and money.

In some cases, however, a fixed list of defined config fields isn’t sufficient to configure a more dynamic feature. Fortunately, it’s relatively straightforward to implement dynamically generated store config fields, allowing developers to support complicated flexibility.

Example Use Case

The requirement for this example is that, for countries which are configured to require a state/region, only certain regions are allowed.

These countries are configured in store config at General -> General -> State Options -> State is Required for. In order to configure which regions are allowed for each country, new store config fields will need to be added, one for each configured country. Since the configured countries could change at any time, it will not be possible to hard code the allowed regions fields in system.xml. Instead, a field will need to be dynamically added to the State Options group for each configured country.

Dynamic Fields

Approach

Each element in the store config hierarchy (tab, section, and group) extends MagentoConfigModelConfigStructureElementAbstractComposite. When setting its data, AbstractComposite specifically looks for an array at key children, and uses this value to populate the children elements.

public function setData(array $data, $scope)
{
    parent::setData($data, $scope);
    $children = array_key_exists(
        'children',
        $this->_data
    ) && is_array(
        $this->_data['children']
    ) ? $this->_data['children'] : [];
    $this->_childrenIterator->setElements($children, $scope);
}

This provides the opportunity to craft a plugin to add (or remove) children, which will eventually be used to populate the store config UI. The parent element of fields is a config group, so MagentoConfigModelConfigStructureElementGroup is the specific class where calls to setData() should be intercepted.




    
        
    



    
        
    
<?php

// EW/DynamicConfigFields/Model/Config/Config/Structure/Element/Section.php

namespace EWDynamicConfigFieldsModelConfigConfigStructureElement;

use MagentoConfigModelConfigStructureElementSection as OriginalSection;
use MagentoDirectoryApiCountryInformationAcquirerInterface;
use MagentoDirectoryApiDataCountryInformationInterface;
use EWDynamicConfigFieldsHelperConfig as ConfigHelper;
use MagentoDirectoryHelperData as DirectoryHelper;

/**
 * Plugin to add dynamically generated groups to
 * General -> General section.
 *
 * @package EWDynamicConfigFieldsModelConfigConfigStructureElement
 */
class Section
{
    /**
     * Config path of target section
     */
    const CONFIG_GENERAL_SECTION_ID = 'general';

    /**
     * @var MagentoDirectoryHelperData
     */
    protected $directoryHelper;
    /**
     * @var CountryInformationAcquirerInterface
     */
    protected $countryInformationAcquirer;

    /**
     * Group constructor.
     * @param DirectoryHelper $directoryHelper
     * @param CountryInformationAcquirerInterface $countryInformationAcquirer
     */
    public function __construct(
        DirectoryHelper $directoryHelper,
        CountryInformationAcquirerInterface $countryInformationAcquirer
    )
    {
        $this->directoryHelper = $directoryHelper;
        $this->countryInformationAcquirer = $countryInformationAcquirer;
    }

    /**
     * Get config options array of regions for given country
     *
     * @param CountryInformationInterface $countryInfo
     * @return array
     */
    protected function getRegionsForCountry(CountryInformationInterface $countryInfo) : array {
        $options = [];

        $availableRegions = $countryInfo->getAvailableRegions() ?: [];

        foreach($availableRegions as $region) {
            $options[$region->getCode()] = [
                'value' => $region->getCode(),
                'label' => $region->getName()
            ];
        }

        return $options;
    }

    /**
     * Get dynamic config groups (if any)
     *
     * @return array
     */
    protected function getDynamicConfigGroups() : array {
        $countriesWithStatesRequired = $this->directoryHelper->getCountriesWithStatesRequired();

        $dynamicConfigGroups = [];
        foreach($countriesWithStatesRequired as $index => $country) {
            // Use a consistent prefix for dynamically generated fields
            // to allow them to be deterministic but not collide with any
            // preexisting fields.
            // ConfigHelper::ALLOWED_REGIONS_CONFIG_PATH_PREFIX == 'regions-allowed-'.
            $configId = ConfigHelper::ALLOWED_REGIONS_CONFIG_PATH_PREFIX . $country;

            $countryInfo = $this->countryInformationAcquirer->getCountryInfo($country);
            $regionOptions = $this->getRegionsForCountry($countryInfo);

            // Use type multiselect if fixed list of regions; otherwise, use textarea.
            $configType = !empty($regionOptions) ? 'multiselect' : 'textarea';

            $dynamicConfigFields = [];
            switch($configType) {
                case 'multiselect':
                    $dynamicConfigFields[$configId] = [
                        'id' => $configId,
                        'type' => 'multiselect',
                        'sortOrder' => ($index * 10), // Generate unique and deterministic sortOrder values
                        'showInDefault' => '1',       // In this case, only show fields at default scope
                        'showInWebsite' => '0',
                        'showInStore' => '0',
                        'label' => __('Allowed Regions: %1', $countryInfo->getFullNameEnglish()),
                        'options' => [                // Since this is a multiselect, generate options dynamically.
                            'option' => $this->getRegionsForCountry($countryInfo)
                        ],
                        'comment' => __(
                            'Select allowed regions for %1.',
                            $countryInfo->getFullNameEnglish()
                        ),
                        '_elementType' => 'field',
                        'path' => implode(            // Compute group path from section ID and dynamic group ID
                            '/',
                            [
                                self::CONFIG_GENERAL_SECTION_ID,
                                ConfigHelper::ALLOWED_REGIONS_SECTION_CONFIG_PATH_PREFIX . $country
                            ]
                        )
                    ];
                    break;
                case 'textarea':
                    $dynamicConfigFields[$configId] = [
                        'id' => $configId,
                        'type' => 'textarea',
                        'sortOrder' => ($index * 10), // Generate unique and deterministic sortOrder values
                        'showInDefault' => '1',       // In this case, only show fields at default scope
                        'showInWebsite' => '0',
                        'showInStore' => '0',
                        'label' => __('Allowed Regions: %1', $countryInfo->getFullNameEnglish()),
                        'comment' => __(
                            'Enter allowed regions for %1, one per line.',
                            $countryInfo->getFullNameEnglish()
                        ),
                        '_elementType' => 'field',
                        'path' => implode(            // Compute group path from section ID and dynamic group ID
                            '/',
                            [
                                self::CONFIG_GENERAL_SECTION_ID,
                                ConfigHelper::ALLOWED_REGIONS_SECTION_CONFIG_PATH_PREFIX . $country
                            ]
                        )
                    ];
                    break;
            }

            $dynamicConfigGroups[$country] = [    // Declare group information
                'id' => $country,                   // Use dynamic group ID
                'label' => __(
                    '%1 Allowed Regions',
                    $countryInfo->getFullNameEnglish()
                ),
                'showInDefault' => '1',             // Show in default scope
                'showInWebsite' => '0',             // Don't show in website scope
                'showInStore' => '0',               // Don't show in store scope
                'sortOrder' => ($index * 10),       // Generate unique and deterministic sortOrder values
                'children' => $dynamicConfigFields  // Use dynamic fields generated above
            ];
        }

        return $dynamicConfigGroups;
    }

    /**
     * Add dynamic region config groups for each country configured
     *
     * @param OriginalSection $subject
     * @param callable $proceed
     * @param array $data
     * @param $scope
     * @return mixed
     */
    public function aroundSetData(OriginalSection $subject, callable $proceed, array $data, $scope) {
        // This method runs for every section.
        // Add a condition to check for the one to which we're
        // interested in adding groups.
        if($data['id'] == self::CONFIG_GENERAL_SECTION_ID) {
            $dynamicGroups = $this->getDynamicConfigGroups();

            if(!empty($dynamicGroups)) {
                $data['children'] += $dynamicGroups;
            }
        }

        return $proceed($data, $scope);
    }
}

Results

Similar to dynamic fields, after this plugin is implemented a dynamic group is shown in the General -> General tab, one for each selected country. (Click for full page screenshot.)

Screenshot of Dynamic Groups in Admin UI

Additionally, values of fields in dynamic groups are correctly saved to core_config_data.

core_config_data dynamic groups screenshot

Where to Go From Here

Retrieving Values

Retrieving values of dynamic fields or fields of dynamic groups is the same as getting any other store config field, except that the path is computed. Below is an example of a config helper to look up the values from this example.

<?php

// EW/DynamicConfigFields/Helper/Config.php

namespace EWDynamicConfigFieldsHelper;

use MagentoFrameworkAppConfigScopeConfigInterface;
use MagentoFrameworkAppHelperAbstractHelper;

class Config extends AbstractHelper
{
    const ALLOWED_REGIONS_GROUP_PATH_PREFIX = 'general/region';
    const ALLOWED_REGIONS_CONFIG_PATH_PREFIX = 'regions-allowed-';

    const ALLOWED_REGIONS_TAB_ID = 'general';
    const ALLOWED_REGIONS_SECTION_CONFIG_PATH_PREFIX = 'allowed-states-section-';

    /**
     * Get configured allowed regions from dynamic fields by country code
     *
     * @param string $countryCode
     * @param string $scopeType
     * @param null $scopeCode
     * @return array
     */
    public function getAllowedRegionsByDynamicField(
        string $countryCode,
        $scopeType = ScopeConfigInterface::SCOPE_TYPE_DEFAULT,
        $scopeCode = null
    ) : array {
        $configPath = implode(
            '/',
            [
                self::ALLOWED_REGIONS_GROUP_PATH_PREFIX,
                self::ALLOWED_REGIONS_CONFIG_PATH_PREFIX . $countryCode
            ]
        );

        $rawValue = $this->scopeConfig->getValue($configPath, $scopeType, $scopeCode);

        // Split on either comma or newline to accommodate both multiselect
        // and textarea field types.
        $parsedValues = preg_split('/[,n]/', $rawValue);

        return $parsedValues;
    }

    /**
     * Get configured allowed regions from fields in dynamic groups
     *
     * @param string $countryCode
     * @param string $scopeType
     * @param null $scopeCode
     * @return array
     */
    public function getAllowedRegionsByDynamicGroup(
        string $countryCode,
        $scopeType = ScopeConfigInterface::SCOPE_TYPE_DEFAULT,
        $scopeCode = null
    ) : array {
        $configPath = implode(
            '/',
            [
                self::ALLOWED_REGIONS_TAB_ID,
                self::ALLOWED_REGIONS_SECTION_CONFIG_PATH_PREFIX . $countryCode,
                self::ALLOWED_REGIONS_CONFIG_PATH_PREFIX . $countryCode
            ]
        );

        $rawValue = $this->scopeConfig->getValue($configPath, $scopeType, $scopeCode);

        // Split on either comma or newline to accommodate both multiselect
        // and textarea field types.
        $parsedValues = preg_split('/[,n]/', $rawValue);

        return $parsedValues;
    }
}

Dynamic Sections and Tabs

Similar to fields and groups, it’s possible to create an around plugin on the setData() method of MagentoConfigModelConfigStructureElementTab and add children one level higher than a group. These dynamic sections will show in the admin as expected. However, clicking on them redirects back to one of the hard-coded sections. Since each section has its own URL, there are probably additional routing concerns for dynamic sections.

Example Module

A complete module demonstrating these code examples is available here: https://github.com/ericthehacker/example-dynamicconfigfields. Use it wisely.

Be aware of the following module notes.

  • The module implements example dynamic fields and groups, as well as methods to retrieve their values. Actually using values to restrict available regions (as expressed in the example use case), however, is left as an exercise for the reader.
  • There is duplicated code between the dynamic fields and groups plugins. This is intentional to ensure that each example plugin is easy to read.

Share it

Topics

Related Posts

Google and Yahoo Have New Requirements for Email Senders

What ROAS Really Means

Everything You Need to Know About Updating to Google Analytics 4

Contact Us