Magento 2 – Inject UI Components via AJAX into Adminhtml Form

javascriptknockoutjsmagento2magento2.3uicomponent

I've build a regular adminhtml form via UI component(s) including multiple fieldsets, input and select fields. All working like they should.

Now I've created a so called 'Event' select field within fieldset #1, who is responsible for hiding and displaying containers within fieldset #2. These containers correspond with the options within the select. This way they can be selected based on the option(s) value. Using this construction enables me to bundle specific fields who belong to the selected 'Event' select option.

This mechanism is required but create two problems;

  1. Hidden 'required' fields are still required when you remove it's parent container from the fieldset/form
  2. When the Events, containers and fields, including there own Source Models, start to grow… this form will become unnecessary heavy to load

Now I'm planning to load the Event container when selected. This way the UI component only loads the Source Model when required. If you switch, the previous container will be destroyed and a new one injected into the form.

This creates two questions;

  1. How to generate a UI component on the fly from within a Controller Action
  2. How to inject these component after a ajax-load

Just to be clear… everything is in place to make this possible. The only thing I need is an example who can answer those two questions.

define([
  'jquery',
  'uiComponent'
], function ($, Component) {
  'use strict';
  return Component.extend({
    defaults: {
      imports: {
        toggleEventContainer: '{event option}:value'
      }
    },

    /*
     * @param event
     * @return {exports}
     */
    toggleEventContainer: function (event)
    {
      // Ajax load here...
      return this;
    }
  });
});

Thanks in advance!

Best Answer

I've investigated this subject a little deeper and tried multiple solutions to fix the problem. Injecting a subform (UI component fields) via AJAX didn't work without having to write a ton of code.

Module\Name\Plugin\Magento\Framework\View\Element\UiComponentFactory

<?php

declare(strict_types=1);

namespace Module\Name\Plugin\Magento\Framework\View\Element;

use Module\Name\Registry\ActionHookEventSelect as ActionHookEventSelectRegistry;
use Magento\Framework\Config\DataInterface;
use Magento\Framework\Config\DataInterfaceFactory;
use Magento\Framework\View\Element\UiComponentFactory as Subject;
use Magento\Framework\View\Element\UiComponentInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Ui\Component\Form as FormComponent;
use Psr\Log\LoggerInterface;

/**
 * Class UiComponentFactory
 *
 * @package Module\Name\Plugin\Magento\Framework\View\Element
 */
class UiComponentFactory
{
    public const FORM_COMPONENT_NS = 'your_form_namespace';

    /** @var ActionHookEventSelectRegistry $actionHookEventSelectRegistry */
    protected $actionHookEventSelectRegistry;

    /** @var LoggerInterface $loggerInterface */
    protected $loggerInterface;

    /** @var DataInterfaceFactory $configFactory */
    protected $configFactory;

    /**
     * UiComponentFactory constructor.
     *
     * @param ActionHookEventSelectRegistry $actionHookEventSelectRegistry
     * @param LoggerInterface               $loggerInterface
     * @param DataInterfaceFactory          $configFactory
     */
    public function __construct(
        ActionHookEventSelectRegistry $actionHookEventSelectRegistry,
        LoggerInterface $loggerInterface,
        DataInterfaceFactory $configFactory
    ) {
        $this->actionHookEventSelectRegistry = $actionHookEventSelectRegistry;
        $this->loggerInterface = $loggerInterface;
        $this->configFactory = $configFactory;
    }

    /**
     * Injects the selection event options UI Component
     * configuration into the form UI Component. A default
     * will be set if no ../adminhtml/ui_component/*.xml exists
     *
     * @param Subject              $subject
     * @param UiComponentInterface $component
     *
     * @return UiComponentInterface
     */
    public function afterCreate(Subject $subject, UiComponentInterface $component): UiComponentInterface
    {
        if ($this->actionHookEventSelectRegistry->has()
            && $component->getName() === self::FORM_COMPONENT_NS) {
            /** @var string $identifier */
            $identifier = $this->actionHookEventSelectRegistry->get();
            /** @var DataInterface $componentData */
            $componentData = $this->configFactory->create(['componentName' => $identifier]);

            if ($componentData->get($identifier) === null) {
                $identifier = $this->actionHookEventSelectRegistry->getDefault(true);
            }

            /** @var array $childComponents */
            $childComponents = $component->getChildComponents();

            if (isset($childComponents[WebhookFormDataProvider::AREA_OPTIONS])) {
                /** @var null|FormComponent $optionsContainer */
                $optionsContainer = null;

                try {
                    $optionsContainer = $subject->create($identifier);
                } catch (LocalizedException $exception) {
                    $this->loggerInterface->error($exception->getMessage());
                }

                if ($optionsContainer) {
                    /** @var FormComponent\Fieldset $parentContainer */
                    $parentContainer = $childComponents[WebhookFormDataProvider::AREA_OPTIONS];

                    // Add custom Event Options container UI component to the 'custom_options'
                    $parentContainer->addComponent($identifier, $optionsContainer);
                    // Re-assign the parent container 'custom_options' back into the form
                    $component->addComponent(WebhookFormDataProvider::AREA_OPTIONS, $parentContainer);
                }
            }
        }

        return $component;
    }
}

Module\Name\Registry\ActionHookEventSelect

<?php

declare(strict_types=1);

namespace Module\Name\Registry;

use Magento\Framework\Exception\AlreadyExistsException;

/**
 * Class ActionHookEventSelect
 *
 * @package Module\Name\Registry
 */
class ActionHookEventSelect
{
    public const DEFAULT_IDENTIFIER = 'missing';
    public const NAMESPACE_PREFIX   = 'form_event_options_';

    /** @var null|string $identifier */
    private $identifier;

    /** @var string $namespacePrefix */
    private $namespacePrefix;

    /**
     * ActionHookEventSelect constructor.
     *
     * @param string $namespacePrefix
     */
    public function __construct(
        string $namespacePrefix = self::NAMESPACE_PREFIX
    ) {
        $this->namespacePrefix = $namespacePrefix;
    }

    /**
     * @param string $identifier
     * @param bool   $validate
     *
     * @return $this
     * @throws AlreadyExistsException
     */
    public function set(string $identifier, $validate = true): self
    {
        if ($validate && $identifier === self::DEFAULT_IDENTIFIER) {
            throw new AlreadyExistsException(
                __('Namespace can not be the same as default')
            );
        }

        $this->identifier = $identifier;
        return $this;
    }

    /**
     * Get selected event namespace
     *
     * @param bool $includePrefix
     *
     * @return string
     */
    public function get($includePrefix = true): string
    {
        $identifier = $this->identifier ?? self::DEFAULT_IDENTIFIER;

        if ($includePrefix) {
            $identifier = $this->getNamespacePrefix() . $identifier;
        }

        return $identifier;
    }

    /**
     * @return bool
     */
    public function has(): bool
    {
        return $this->get(false) !== self::DEFAULT_IDENTIFIER;
    }

    /**
     * @return string
     */
    public function getNamespacePrefix(): string
    {
        return $this->namespacePrefix;
    }

    /**
     * @param bool $includePrefix
     *
     * @return string
     */
    public function getDefault($includePrefix = false): string
    {
        $current = $this->get(false);

        try {
            $this->set(self::DEFAULT_IDENTIFIER, false);
            $default = $this->get($includePrefix);
            $this->set($current);
        } catch (AlreadyExistsException $exception) {
            return self::NAMESPACE_PREFIX . self::DEFAULT_IDENTIFIER;
        }

        return $default;
    }
}

This enabled you to create your own custom UI-component XML files within any custom module you like. The above example registers a selected 'event'. This can happen for example within a Controller Action where you set it on the Registry.

public function execute()
{
    //...
    try {
        $this->actionHookEventSelectRegistry->set($event);
    } catch (AlreadyExistsException $exception) {
        $this->messageManager->addErrorMessage('Could not instantiate the event options');
    }
    //...
}

Hope this helps others! I'm really happy with the final result despite it required a different approach.

Related Topic