Custom configuration entity type

If you remember NodeType from the previous chapter, you know the essentials of creating custom configuration entity types. So, let's create our Importer type now. Like before, we start with the annotation part, which this time is a ConfigEntityType:

namespace Drupal\products\Entity;

use Drupal\Core\Config\Entity\ConfigEntityBase;

/**
* Defines the Importer entity.
*
* @ConfigEntityType(
* id = "importer",
* label = @Translation("Importer"),
* handlers = {
* "list_builder" = "Drupal\products\ImporterListBuilder",
* "form" = {
* "add" = "Drupal\products\Form\ImporterForm",
* "edit" = "Drupal\products\Form\ImporterForm",
* "delete" = "Drupal\products\Form\ImporterDeleteForm"
* },
* "route_provider" = {
* "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
* },
* },
* config_prefix = "importer",
* admin_permission = "administer site configuration",
* entity_keys = {
* "id" = "id",
* "label" = "label",
* "uuid" = "uuid"
* },
* links = {
* "add-form" = "/admin/structure/importer/add",
* "edit-form" = "/admin/structure/importer/{importer}/edit",
* "delete-form" = "/admin/structure/importer/{importer}/delete",
* "collection" = "/admin/structure/importer"
* },
* config_export = {
* "id",
* "label",
* "url",
* "plugin",
* "update_existing",
* "source",
* "bundle"
* }
* )
*/
class Importer extends ConfigEntityBase implements ImporterInterface {}

As with the Product entity, we will need to create a list builder handler, as well as form handlers. In this case, though, we also need to create a form handler for the delete operation as we will soon see why. Finally, since we have a configuration entity, we also specify the config_export and config_prefix keys to be used for the exporting. If you remember from the previous chapter, the first one denotes the names of the fields that should be persisted (we'll see them in a minute), while the second denotes the prefix the configuration names should get when stored. One thing you'll note is that we don't have a canonical link because we don't really need one—our entities don't need a details page, hence no canonical link to it needs to be defined.

Now, it's time to create the ImporterInterface that the entities implement. It is named the same as the plugin interface we created earlier, but it resides in a different namespace:

namespace Drupal\products\Entity;

use Drupal\Core\Config\Entity\ConfigEntityInterface;
use Drupal\Core\Url;

/**
* Importer configuration entity.
*/
interface ImporterInterface extends ConfigEntityInterface {

/**
* Returns the Url where the import can get the data from.
*
* @return Url
*/
public function getUrl();

/**
* Returns the Importer plugin ID to be used by this importer.
*
* @return string
*/
public function getPluginId();

/**
* Whether or not to update existing products if they have already been imported.
*
* @return bool
*/
public function updateExisting();

/**
* Returns the source of the products.
*
* @return string
*/
public function getSource();
}

In these configuration entities, we want to store, for now, a URL to the resource where the products can be retrieved from, the ID of the importer plugin to use, whether we want existing products to be updated if they had already been imported, and the source of the products. For all these fields, we create some getter methods. You'll note that getUrl() needs to return a Url instance. Again, we create a well-defined interface for the public API of the entity type as we did with the product entity type.

And this is what the Importer class body that implements this interface looks like:

/**
* The Importer ID.
*
* @var string
*/
protected $id;

/**
* The Importer label.
*
* @var string
*/
protected $label;

/**
* The URL from where the import file can be retrieved.
*
* @var string
*/
protected $url;

/**
* The plugin ID of the plugin to be used for processing this import.
*
* @var string
*/
protected $plugin;

/**
* Whether or not to update existing products if they have already been imported.
*
* @var bool
*/
protected $update_existing = TRUE;

/**
* The source of the products.
*
* @var string
*/
protected $source;

/**
* {@inheritdoc}
*/
public function getUrl() {
return $this->url ? Url::fromUri($this->url) : NULL;
}

/**
* {@inheritdoc}
*/
public function getPluginId() {
return $this->plugin;
}

/**
* {@inheritdoc}
*/
public function updateExisting() {
return $this->update_existing;
}

/**
* {@inheritdoc}
*/
public function getSource() {
return $this->source;
}

If you remember from the previous chapter, defining fields on a configuration entity type is as simple as defining properties on the class itself. Moreover, you may recall the config_export key on the annotation, which lists which of these properties need to be exported and persisted. We omitted that because we will simply rely on the configuration schema (which we will create soon). Lastly, the interface methods are implemented next, and there is no rocket science involved in that. The getUrl(), as expected, will try to create an instance of Url from the value.

Let's not forget the use statement for it at the top:

use Drupal\Core\Url;  

Since we talked about the configuration schema, let's define that as well. If you remember, it goes inside the config/schema folder of our module in a *.schema.yml file. This can be named after the module and contains the schema definitions of all configurations of the module. Alternatively, it can be named after the individual configuration entity type, so, in our case, importer.schema.yml (to keep things neatly organized):

products.importer.*:
type: config_entity
label: 'Importer config'
mapping:
id:
type: string
label: 'ID'
label:
type: label
label: 'Label'
uuid:
type: string
url:
type: uri
label: Uri
plugin:
type: string
label: Plugin ID
update_existing:
type: boolean
label: Whether to update existing products
source:
type: string
label: The source of the products

If you recall, the wildcard is used to apply the schema to all configuration items that match the prefix. So, in our case, it will match all importer configuration entities. Next, we have the config_entity schema with a mapping of the fields we defined. Apart from the default fields each entity type comes with, we are using a uri, string, and boolean schema type (which under the hood maps to the corresponding TypedData data type plugins). This schema now helps the system understand our entities.

Now, let's go ahead and create the list builder handler that will take care of the admin entity listing:

namespace Drupal\products;

use Drupal\Core\Config\Entity\ConfigEntityListBuilder;
use Drupal\Core\Entity\EntityInterface;

/**
* Provides a listing of Importer entities.
*/
class ImporterListBuilder extends ConfigEntityListBuilder {

/**
* {@inheritdoc}
*/
public function buildHeader() {
$header['label'] = $this->t('Importer');
$header['id'] = $this->t('Machine name');
return $header + parent::buildHeader();
}

/**
* {@inheritdoc}
*/
public function buildRow(EntityInterface $entity) {
$row['label'] = $entity->label();
$row['id'] = $entity->id();
return $row + parent::buildRow($entity);
}
}

This time we are extending the ConfigEntityListBuilder, which provides some functionalities specific to configuration entities. However, we are essentially doing the same as with the products listing—setting up the table header and the individual row data, nothing major. I recommend that you inspect ConfigEntityListBuilder and see what else you can do in the subclass.

Now, we can finally take care of the form handler and start with the default create/edit form:

namespace Drupal\products\Form; 
 
use Drupal\Core\Entity\EntityForm; 
use Drupal\Core\Form\FormStateInterface; 
use Drupal\Core\Messenger\MessengerInterface; 
use Drupal\Core\Url; 
use Drupal\products\Plugin\ImporterManager; 
use Symfony\Component\DependencyInjection\ContainerInterface; 
 
/** 
 * Form for creating/editing Importer entities. 
 */ 
class ImporterForm extends EntityForm { 
 
  /** 
   * @var \Drupal\products\Plugin\ImporterManager 
   */ 
  protected $importerManager; 
 
  /** 
   * ImporterForm constructor. 
   * 
   * @param \Drupal\products\Plugin\ImporterManager $importerManager 
   * @param \Drupal\Core\Messenger\MessengerInterface $messenger 
   */ 
  public function __construct(ImporterManager $importerManager, MessengerInterface $messenger) { 
    $this->importerManager = $importerManager; 
    $this->messenger = $messenger; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public static function create(ContainerInterface $container) { 
    return new static( 
      $container->get('products.importer_manager'), 
      $container->get('messenger') 
    ); 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function form(array $form, FormStateInterface $form_state) { 
    $form = parent::form($form, $form_state); 
 
    /** @var \Drupal\products\Entity\Importer $importer */ 
    $importer = $this->entity; 
 
    $form['label'] = [ 
      '#type' => 'textfield', 
      '#title' => $this->t('Name'), 
      '#maxlength' => 255, 
      '#default_value' => $importer->label(), 
      '#description' => $this->t('Name of the Importer.'), 
      '#required' => TRUE, 
    ]; 
 
    $form['id'] = [ 
      '#type' => 'machine_name', 
      '#default_value' => $importer->id(), 
      '#machine_name' => [ 
        'exists' => '\Drupal\products\Entity\Importer::load', 
      ], 
      '#disabled' => !$importer->isNew(), 
    ]; 
 
    $form['url'] = [ 
      '#type' => 'url', 
      '#default_value' => $importer->getUrl() instanceof Url ? $importer->getUrl()->toString() : '', 
      '#title' => $this->t('Url'), 
      '#description' => $this->t('The URL to the import resource'), 
      '#required' => TRUE, 
    ]; 
 
    $definitions = $this->importerManager->getDefinitions(); 
    $options = []; 
    foreach ($definitions as $id => $definition) { 
      $options[$id] = $definition['label']; 
    } 
 
    $form['plugin'] = [ 
      '#type' => 'select', 
      '#title' => $this->t('Plugin'), 
      '#default_value' => $importer->getPluginId(), 
      '#options' => $options, 
      '#description' => $this->t('The plugin to be used with this importer.'), 
      '#required' => TRUE, 
    ]; 
 
    $form['update_existing'] = [ 
      '#type' => 'checkbox', 
      '#title' => $this->t('Update existing'), 
      '#description' => $this->t('Whether to update existing products if already imported.'), 
      '#default_value' => $importer->updateExisting(), 
    ]; 
 
    $form['source'] = [ 
      '#type' => 'textfield', 
      '#title' => $this->t('Source'), 
      '#description' => $this->t('The source of the products.'), 
      '#default_value' => $importer->getSource(), 
    ]; 
 
    return $form; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function save(array $form, FormStateInterface $form_state) { 
    /** @var \Drupal\products\Entity\Importer $importer */ 
    $importer = $this->entity; 
    $status = $importer->save(); 
 
    switch ($status) { 
      case SAVED_NEW: 
        $this->messenger->addMessage($this->t('Created the %label Importer.', [ 
          '%label' => $importer->label(), 
        ])); 
        break; 
 
      default: 
        $this->messenger->addMessage($this->t('Saved the %label Importer.', [ 
          '%label' => $importer->label(), 
        ])); 
    } 
     
    $form_state->setRedirectUrl($importer->toUrl('collection')); 
  } 
 
} 

We are directly extending EntityForm in this case because configuration entities don't have a specific form class like content entities do. For this reason, we also have to implement the form elements for all our fields inside the form() method.

But first things first. We know we want the configuration entity to select a plugin to use, so, for this reason, we inject the ImporterManager we created earlier. We will use it to get all the existing definitions. And we also inject the Messenger service to use it later to print a message to the user.

Inside the form() method, we define all the form elements for the fields. We use a textfield for the label and a machine_name field for the ID of the entity. The latter is a special JavaScript-powered field that derives its value from a "source" field (which defaults to the field label if one is not specified). It is also disabled if we are editing the form and is using a dynamic callback to try to load an entity by the provided ID and will fail validation if it exists. This is useful to ensure that IDs do not repeat. Next, we have a url form element, which does some URL-specific validation and handling to ensure that a proper URL is added. Then, we create an array of select element options of all the available importer plugin definitions. For this, we use the plugin manager's getDefinitions(), from which we can get the IDs and labels. A plugin definition is an array that primarily contains the data found in the annotation and some other data processed and added by the manager (in our case, only defaults). At this stage, our plugins are not yet instantiated. And we use those options on the select list. Finally, we have the simple checkbox and textfield elements for the last two fields, as we want to store the update_existing field as a Boolean and the source as a string.

The save() method is pretty much like it was in the Product entity form; we are simply displaying a message and redirecting the user to the entity listing page (using the handy toUrl() method on the entity to build the URL). Since we named the form elements exactly the same as the fields, we don't need to do any mapping of the form values to the field names. That is taken care of.

Let's now write the delete form handler:

namespace Drupal\products\Form; 
 
use Drupal\Core\Entity\EntityConfirmFormBase; 
use Drupal\Core\Form\FormStateInterface; 
use Drupal\Core\Messenger\MessengerInterface; 
use Drupal\Core\Url; 
use Symfony\Component\DependencyInjection\ContainerInterface; 
 
/** 
 * Form for deleting Importer entities. 
 */ 
class ImporterDeleteForm extends EntityConfirmFormBase { 
 
  /** 
   * ImporterDeleteForm constructor. 
   * 
   * @param \Drupal\Core\Messenger\MessengerInterface $messenger 
   */ 
  public function __construct(MessengerInterface $messenger) { 
    $this->messenger = $messenger; 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public static function create(ContainerInterface $container) { 
    return new static( 
      $container->get('messenger') 
    ); 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function getQuestion() { 
    return $this->t('Are you sure you want to delete %name?', ['%name' => $this->entity->label()]); 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function getCancelUrl() { 
    return new Url('entity.importer.collection'); 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function getConfirmText() { 
    return $this->t('Delete'); 
  } 
 
  /** 
   * {@inheritdoc} 
   */ 
  public function submitForm(array &$form, FormStateInterface $form_state) { 
    $this->entity->delete(); 
 
    $this->messenger->addMessage($this->t('Deleted @entity importer.', ['@entity' => $this->entity->label()])); 
 
    $form_state->setRedirectUrl($this->getCancelUrl()); 
  } 
   
} 

As I mentioned earlier, for configuration entities, we will need to implement this form handler ourselves. However, it's not a big deal because we can extend EntityConfirmFormBase and just implement some simple methods:

And with this, we are done with our configuration entity type. The last thing we might want to do is create some menu links to be able to navigate to the relevant pages (the same as we did for the product entity type). For the entity list page, we can have this in our products.links.menu.yml file:

# Importer entity menu items
entity.importer.collection:
title: 'Importer list'
route_name: entity.importer.collection
description: 'List Importer entities'
parent: system.admin_structure
weight: 99

There's nothing new here. We can also create the action link to add a new entity inside the products.links.action.yml file:

entity.importer.add_form:
route_name: 'entity.importer.add_form'
title: 'Add Importer'
appears_on:
- entity.importer.collection

We do the same thing here as we did with the products. However, we won't create local tasks because we don't have a canonical route for the configuration entities, so we don't really need it.

Now, if we clear our cache and go to admin/structure/importer, we should see the empty importer entity listing: