Drupal: Ergebnis eines Autocomplete-Feldes anpassen

Das Problem

In einer Drupal 9.5-Installation nutzen wir das Group-Modul. Damit kann man Themenräume umsetzen, denen man dann Benutzer und Inhalte hinzufügt. Bisher konnte jeder Inhalt mehreren Themenräumen zugeordnet werden. Gewünscht war nun, dass jeder Inhalt immer nur in einer Gruppe verlinkt sein kann. Wenn man den Gruppentyp bearbeitet und auf dem „Inhalt“-Tab den entsprechenden Inhaltstyp konfiguriert (für Gruppentyp „unit“ und Inhaltstyp „News“ z.B. „admin/group/content/manage/unit-group_node-news“), kann man dort die Gruppenkardinalität auf 1 stellen.

Soweit so schön. Wenn man an der Gruppe eine bestehende Entität (z.B. einen News-Node) hinzufügen möchte, gibt es dafür ein Autocomplete-Feld. Das bietet das Group-Modul so an neben ein paar anderen Varianten und konfiguriert das auch für einen. Man hat über das Autocomplete-Feld also nicht so ganz die Hoheit, was da eingestellt wird. Man tippt einen Teil des Titels ein und das Feld bietet News-Nodes an:

Nehmen wir nun mal an, die erste Testnews wäre bereits einem anderen Themenraum zugeordnet. Sie wird mir aber trotzdem angeboten. Erst wenn ich die Auswahl speichere, wird das validiert und das Speichern wird abgelehnt:

Schöner wäre es doch aber, wenn man Dinge, die man nicht hinzufügen kann, gar nicht erst angeboten bekäme.

Die Lösung

Das umzusetzen war erstaunlich schwierig, obwohl die fertige Lösung vergleichsweise kurz ist. Wie immer bei Drupal ist es unter einigen Abstraktionsschichten versteckt und wenig bis gar nicht dokumentiert.

Mein erster Ansatz war, die Klasse zu überschreiben, welche die Query ausführt. Da ich das Autocomplete-Feld aber nicht selber hinzufüge, ist das gar nicht so einfach. Bei der Suche bin ich zum Glück im Webform-Modul auf eine einfachere Art gestoßen, diese Query zu modifizieren. Das ganze funktioniert in zwei Teilen. Zum einen müssen wir prüfen, ob die Gruppenkardinalität auf 1 gesetzt ist. Das machen wir einmal beim Laden des Formulars. Es ist ein wenig Aufwand, aber beim einmaligen Laden fallen die paar Extra-Abfragen nicht zu sehr ins Gewicht. Wie üblich gibt es dafür einen Form-Alter-Hook, den wir in einem eigenen Modul „mymodule“ platzieren:

/**
 * Implements hook_form_FORM_ID_alter().
 */
function mymodule_form_group_content_form_alter(&$form, FormStateInterface $form_state, $form_id): void {
  // For all "add group content" forms, add a hook to extend the query.
  $matches = [];
  if (str_starts_with($form_id, 'group_content')
    && preg_match('/^group_content_unit-group_([-_\w]+)_add_form$/', $form_id, $matches)) {
    // Get the group content type's group cardinality and the name of the
    // content type's ID column.
    $formObject = $form_state->getFormObject();
    if ($formObject instanceof ContentEntityFormInterface) {
      $groupContent = $formObject->getEntity();
      if ($groupContent instanceof GroupContentInterface) {
        // Get the group cardinality.
        $plugin = $groupContent->getContentPlugin();
        $groupCardinality = $groupContent->getContentPlugin()->getGroupCardinality();

        // If the cardinality is 1, add the type of content and the name of the
        // PK column to the selection settings.
        if ($groupCardinality === 1) {
          // Get the name of the linked content type's Primary Key column ("nid"
          // for nodes, but might be something else for other content types).
          $entityTypeId = $plugin->getEntityTypeId();
          $typeStorage = \Drupal::entityTypeManager()->getStorage($entityTypeId);
          $idColumn = $typeStorage->getEntityType()->getKey('id');

          // Add it to the selection settings.
          $form['entity_id']['widget'][0]['target_id']['#selection_settings']['_mymodule_settings'] = [
            'target_type' => $matches[1],
            'id_column' => $idColumn
          ];
        }
      }
    }
  }
}

Hinweise dazu:

  1. Der Hook befindet sich in unserem eigenen Modul, läuft aber für ALLE Formulare. Es ist also sehr wichtig als erstes so einfach wie möglich zu prüfen, ob wir im gewünschten Formular sind. Deswegen auch die str_starts_with-Prüfung, welche etwas schneller ist als das preg_match.
  2. Im preg_match müsst ihr „unit-group“ durch den technischen Namen eures Gruppentyps ersetzen!
  3. Diese Lösung behandelt den Spezialfall der Gruppenkardinalität 1. Man könnte das auch anpassen, um andere Werte größer als 1 zu behandeln. Dann sollte man hier die Kardinalität mit ins Settings-Array schreiben.
  4. Die folgenden Code-Teile kann man sicher auch etwas kürzer formulieren, aber mit den zusätzlichen Typprüfungen zeigt PHPStorm keine Warnungen bzgl. der Verfügbarkeit von Methoden an.
  5. Neben der Prüfung der Kardinalität ermitteln wir auch noch den Namen der Primary-Key-Spalte des verlinkten Inhalts. Für Nodes ist das immer „nid“, und wenn ihr den Gruppen nur Nodes hinzufügt, könnt ihr euch das ggf. sparen. Ich hatte hier noch einen anderen Content-Type, der eine andere ID-Spalte verwendet.
  6. Alles in dem if könnte man in eine eigene Klasse auslagern, um das Modul nicht mit Code zu überfachten.

Mit diesem Hook haben wir nun in den Selection-Settings des Forms unsere eigenen Informationen unter einem neuen Key hinzugefügt. Dadurch passiert aber erst mal noch nichts. Die Magie passiert in einem zweiten Hook, welcher ins gleiche Modul kommt und dazu dient, speziell die Autocomplete-Query anzupassen, ehe sie ausgeführt wird:

/**
 * Implements hook_query_TAG_alter().
 */
function mymodule_query_entity_reference_alter(AlterableInterface $query): void {
  // Get our own _mymodule_settings out of the handler's selection settings.
  /** @var \Drupal\Core\Entity\Plugin\EntityReferenceSelection\DefaultSelection $handler */
  $handler = $query->getMetaData('entity_reference_selection_handler');
  $settings = $handler->getConfiguration()['_mymodule_settings'] ?? [];

  // If requested, filter out all data for which a group content already exist.
  // They will simply not be offered in the autocomplete anymore.
  if (!empty($settings['target_type'])) {
    $targetType = $settings['target_type'];
    $idColumn = $settings['id_column'];
    $subQuery = \Drupal::database()
      ->select('group_content_field_data', 'gcfd')
      ->fields('gcfd', ['entity_id'])
      ->condition('type', "unit-group_{$targetType}");
    $query->condition('base_table.' . $idColumn, $subQuery, 'NOT IN');
  }
}

Hinweise dazu:

  1. Wir lesen hier die zuvor gesetzten Settings wieder aus. Falls sie nicht da sind, passiert nichts. Das ist wichtig, denn der Hook läuft immer, wenn eine „entity_reference“-Query ausgeführt wird, also nicht nur bei Gruppen-Typen.
  2. Wenn wir uns im richtigen Formular befinden, wird hier nun die Query um eine weitere Abfrage ergänzt: Es werden nur noch Entities ausgelesen, für welche bisher kein „group_content“-Datensatz vom richtigen Typ existiert. Die Typ-Prüfung ist wichtig, da verschiedene Content-Arten ja die gleichen IDs haben könnten.
  3. In der condition der Sub-Query müsst ihr wieder „unit-group“ durch den technischen Namen eures Gruppentyps ersetzen.

Und das Ergebnis sieht dann so aus:

News-Entities, welche bereits einer anderen Gruppe (oder auch der gleichen) hinzugefügt wurden, werden im Autocomplete nicht mehr angeboten. Das muss den Nutzern natürlich kommuniziert werden. Diese Seite könnte also einen kleinen Hilfetext zur Erklärung vertragen.

Das Manipulieren einer Query ist außerdem anfällig dafür, dass sich in zukünftigen Drupal-Versionen etwas an der Query ändert, z.B. der „basetable“-Alias, und das dann kaputt geht. Absichern mit einem Test wäre also ideal.

Fazit

Eigentlich nicht so schwierig, wenn man weiß wie es geht Bei aller Komplexität und Abstraktion ist es immer schön, wenn man Dinge über Hooks anpassen kann. Ich hoffe, diese Anleitung erspart dem ein oder anderen einen Teil der Sucherei.

Bonus: Refactoring

Ok, weil das einfach furchtbar aussah und die Kollegen das so eh nicht durch die Code-Review gelassen hätten, hier noch die Version nach dem Refactoring. Das Modul ist jetzt schön kurz und knapp:

/**
 * Implements hook_form_FORM_ID_alter().
 *
 * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
 *   If the content type storage can not be loaded.
 * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
 *   If the content type storage can not be loaded.
 */
function mymodule_form_group_content_form_alter(&$form, FormStateInterface $form_state, $form_id): void {
  // For all "add group content" forms, add a hook to extend the query.
  $matches = [];
  if (str_starts_with($form_id, 'group_content')
    && preg_match('/^group_content_unit-group_([-_\w]+)_add_form$/', $form_id, $matches)) {
    GroupContentAddForm::formAlter($form, $form_state, $matches[1]);
  }
}

/**
 * Implements hook_query_TAG_alter().
 */
function mymodule_query_entity_reference_alter(AlterableInterface $query): void {
  GroupContentAddForm::alterEntityReferenceQuery($query);
}

Und dazu gibt es die neue Klasse „GroupContentAddForm“:

<?php
// Namespace + use-Statements...

/**
 * Contains helper methods for the form to add group contents.
 */
class GroupContentAddForm {

  /**
   * Adds a hook to extend the query to all "add group content" forms.
   *
   * @param $form
   *   The form to alter.
   * @param \Drupal\Core\Form\FormStateInterface $form_state
   *   The form state.
   * @param string $entityTypeId
   *   The technical name of the target content entity.
   *
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   *   If the content type storage can not be loaded.
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   *   If the content type storage can not be loaded.
   */
  public static function formAlter(&$form, FormStateInterface $form_state, string $entityTypeId): void {
    // Get the new, unsaved group entity out of the form.
    $formObject = $form_state->getFormObject();
    if (!($formObject instanceof ContentEntityFormInterface)) {
      return;
    }
    $groupContent = $formObject->getEntity();
    if (!($groupContent instanceof GroupContentInterface)) {
      return;
    }

    // Get the group content type's group cardinality.
    // We are only considering the special case of cardinality === 1.
    $plugin = $groupContent->getContentPlugin();
    $groupCardinality = $groupContent->getContentPlugin()->getGroupCardinality();
    if ($groupCardinality !== 1) {
      return;
    }

    // Get the name of the linked content type's Primary Key column ("nid"
    // for nodes, but might be something else for other content types).
    $typeStorage = \Drupal::entityTypeManager()->getStorage(
      $plugin->getEntityTypeId()
    );
    $idColumn = $typeStorage->getEntityType()->getKey('id');

    // Add both to the selection settings.
    $form['entity_id']['widget'][0]['target_id']['#selection_settings']['_mymodule_settings'] = [
      'target_type' => $entityTypeId,
      'id_column' => $idColumn
    ];
  }

  /**
   * Alters an "entity reference" query if our own settings are present.
   *
   * The settings have been added to the query's metadata in the formAlter
   * method.
   *
   * @param \Drupal\Core\Database\Query\AlterableInterface $query
   *   The changeable query.
   */
  public static function alterEntityReferenceQuery(AlterableInterface $query): void {
    // Get our own _mymodule_settings out of the handler's selection settings.
    /** @var \Drupal\Core\Entity\Plugin\EntityReferenceSelection\DefaultSelection $handler */
    $handler = $query->getMetaData('entity_reference_selection_handler');
    $settings = $handler->getConfiguration()['_mymodule_settings'] ?? [];
    if (empty($settings)) {
      return;
    }

    // If requested, filter out all data for which a group content already exist.
    // They will simply not be offered in the autocomplete anymore.
    if (!empty($settings['target_type'])) {
      $targetType = $settings['target_type'];
      $idColumn = $settings['id_column'];
      $subQuery = \Drupal::database()
        ->select('group_content_field_data', 'gcfd')
        ->fields('gcfd', ['entity_id'])
        ->condition('type', "unit-group_{$targetType}");
      $query->condition('base_table.' . $idColumn, $subQuery, 'NOT IN');
    }
  }

}

Das sieht doch gleich lesbarer aus! 🙂

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.

Bitte beachte die Kommentarregeln: 1) Kein Spam, und bitte höflich bleiben. 2) Ins Namensfeld gehört ein Name. Gerne ein Pseudonym, aber bitte keine Keywords. 3) Keine kommerziellen Links, außer es hat Bezug zum Beitrag. mehr Details...

So, noch mal kurz drüber schauen und dann nichts wie ab damit. Vielen Dank fürs Kommentieren! :-)