1

We created a custom field type featuring a computed classes property that returns a simple array of generated CSS classes. The field itself allows content editors to define styling options depending on responsive breakpoints. Given the content editors choices, the CSS classes will be generated using a CSS class pattern defined within the field storage settings. For now, the property is generated and added to the field value within the getValue() method of our field item:

src/Plugin/Field/FieldItem/ResponsiveClassItem.php

/**
 * Field item of type 'responsive_class'.
 *
 * @FieldType(
 *   id = "responsive_class",
 *   label = @Translation("Responsive class"),
 *   module = "responsive_class_field",
 *   default_widget = "responsive_class_widget",
 *   default_formatter = "responsive_class_formatter",
 *   cardinality = 1,
 *   translatable = FALSE,
 * )
 */
class ResponsiveClassItem extends FieldItemBase implements FieldItemInterface {
  [...]

  /**
   * {@inheritdoc}
   */
  public static function schema(FieldStorageDefinitionInterface $field_definition) {
    return [
      'columns' => [
        'value' => [
          'description' => 'The selected responsive styles.',
          'type' => 'blob',
          'not null' => TRUE,
          'serialize' => TRUE,
        ],
      ],
    ];
  }

  /**
   * {@inheritdoc}
   */
  public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) {
    $properties = [];
    $properties['value'] = DataDefinition::create('responsive_class')
      ->setLabel(new TranslatableMarkup('Responsive class settings'))
      ->setRequired(FALSE);;

    $properties['classes'] = DataDefinition::create('responsive_class_classes')
      ->setLabel(new TranslatableMarkup('Generated responsive classes'))
      ->setComputed(TRUE)
      ->setReadOnly(TRUE);

    return $properties;
  }

  /**
   * {@inheritdoc}
   */
  public function getValue() {
    $values = parent::getValue();
    $values['classes'] = $this->getClasses($values['value']);
    return $values;
  }

  /**
   * Generate and return responsive classes.
   *
   * Uses the 'pattern' field storage setting to generate a class for every
   * breakpoint and return them as classes array. The pattern may contain
   * the placeholders {breakpoint} and {value} that will be replaced by the
   * configured breakpoint values.
   *
   * @param array $breakpoints
   *   Array of breakpoint values, whereas every breakpoint value is an
   *   associative array with the following keys:
   *   - breakpoint_id: ID of the breakpoint in the breakpoints definition.
   *   - breakpoint: Replacement value for the {breakpoint} token.
   *   - value: Replacement value for the {value} token.
   *
   * @return array
   *   Array of generated classes.
   */
  protected function getClasses(array $breakpoints) {
    $pattern = $this->getSetting('pattern');
    $classes = [];
    foreach ($breakpoints as $value) {
      $class = str_replace('{breakpoint}', $value['breakpoint'], $pattern);
      $class = str_replace('{value}', $value['value'], $class);
      $class = Html::getClass($class);
      $classes[] = $class;
    }
    return $classes;
  }

}

src/Plugin/DataType/ResponsiveClassClassesData.php

/**
 * Data type for responsive class field classes.
 *
 * @DataType(
 *   id = "responsive_class_classes",
 *   label = @Translation("Responsive Class Classes"),
 *   description = @Translation("Generated classes of the responsive class field."),
 * )
 */
class ResponsiveClassClassesData extends TypedData {

}

It lets us access the generated classes of the field via the value array and attach it to it's parent entities attribute classes like so:

responsive_class_field.module

/**
 * Implements hook_entity_display_build_alter().
 */
function responsive_class_field_entity_view_alter(array &$build, EntityInterface $entity, EntityViewDisplayInterface $display) {
  if (!$entity instanceof FieldableEntityInterface) {
    return;
  }

  $field_definitions = $entity->getFieldDefinitions();
  foreach ($field_definitions as $field_name => $field_definition) {
    if ($field_definition->getType() != 'responsive_class') {
      continue;
    }

    $field = $entity->get($field_name);
    if ($field->isEmpty()) {
      continue;
    }

    $classes = $field->getValue()[0]['classes'];
    foreach ($classes as $class) {
      $build['#attributes']['class'][] = $class;
    }
  }
}

And now coming to the real question

While the above approach is working, I have the strong feeling that computed properties aren't intended to just being attached in the getValue() method. So I'm seeking to improve the source code.

Spending decent hours diving into core source code and field API documentation left me without a better solution. What is the 'Drupal way' to providing such a property?

1

If you set a property to computed this only says the property is not saved or loaded. You can then populate the values at runtime with any method, but the proper Drupal way would be to use TypedData::getValue, so in your case ResponsiveClassClassesData::getValue(). At the moment your data type class is useless, see the documentation of TypedData:

/**
 * The abstract base class for typed data.
 *
 * Classes deriving from this base class have to declare $value
 * or override getValue() or setValue().
  • We followed your advice and implemented some basic methods for getting/setting the value in our TypedData class. (Even it was working without any further overrides as well.) I might add an own answer later to show the entire implementation to interested readers looking to implement their own computed fields. – Mario Steinitz Aug 20 '18 at 3:56
  • The entire implementation is a great example. Storing the result statically might not be necessary in your case, but could help others with more time consuming calculations. (For a simple implementation you need to declare $value, see Drupal\Core\TypedData\Plugin\DataType\Any, then you can use methods in the field item to calculate the field values. Or better set a TypedData class directly to the field without annotation based discovery implementing only getValue()). – 4k4 Aug 20 '18 at 6:26
  • Unfortunately this isn't a too well documented topic at drupal.org. And if you don't need it on a daily base... - There are a handfull of blog posts in the web and also some usages within Drupal core. They all differ somehow. It took my team and me a while to get it running. So I thought providing our solution as example might be of help for others searching for answers about the same topic. – Mario Steinitz Aug 20 '18 at 6:39
2

Following the advice of @4k4, the final solution includes overriding TypedData::setValue and TypedData::getValue within our ResponsiveClassClassesData class. Furthermore, we don't attach the values of the computed property within our field's getValue method, but set them within the field's setValue method and define the property as non-internal. This way, the processed value can be accessed by simply using $field->classes.

The resulting classes:

src/Plugin/Field/FieldItem/ResponsiveClassItem.php

/**
 * Field item of type 'responsive_class'.
 *
 * @FieldType(
 *   id = "responsive_class",
 *   label = @Translation("Responsive class"),
 *   module = "responsive_class_field",
 *   default_widget = "responsive_class_widget",
 *   default_formatter = "responsive_class_formatter",
 *   cardinality = 1,
 *   translatable = false,
 * )
 */
class ResponsiveClassItem extends FieldItemBase implements FieldItemInterface {
  [...]

  /**
   * {@inheritdoc}
   */
  public static function schema(FieldStorageDefinitionInterface $field_definition) {
    return [
      'columns' => [
        'value' => [
          'description' => 'The selected responsive styles.',
          'type' => 'blob',
          'not null' => TRUE,
          'serialize' => TRUE,
        ],
      ],
    ];
  }

  /**
   * {@inheritdoc}
   */
  public static function propertyDefinitions(FieldStorageDefinitionInterface $field_definition) {
    $properties = [];
    $properties['value'] = DataDefinition::create('responsive_class')
      ->setLabel(new TranslatableMarkup('Responsive class settings'))
      ->setRequired(FALSE);;

    $properties['classes'] = DataDefinition::create('responsive_class_classes')
      ->setLabel(new TranslatableMarkup('Generated responsive classes'))
      ->setComputed(TRUE)
      ->setClass('\Drupal\responsive_class_field\Plugin\DataType\ResponsiveClassClassesData')
      ->setInternal(FALSE);

    return $properties;
  }

  /**
   * {@inheritdoc}
   */
  public function setValue($values, $notify = TRUE) {
    if (isset($values['value'])) {
      // Single serialized values on shared tables for base fields are not
      // always unserialized. https://www.drupal.org/node/2788637
      if (is_string($values['value'])) {
        $values['value'] = unserialize($values['value']);
      }

      // Only include values that differ from the empty_value.
      $breakpoints_to_save = $this->getNonEmptyValues($values['value']);

      $values['value'] = $breakpoints_to_save;
      $values['classes'] = $this->getClasses($breakpoints_to_save);
    }

    parent::setValue($values, $notify);
  }

  /**
   * Generate and return responsive classes.
   *
   * Uses the 'pattern' field storage setting to generate a class for every
   * breakpoint and return them as classes array. The pattern may contain
   * the placeholders {breakpoint} and {value} that will be replaced by the
   * configured breakpoint values.
   *
   * @param array $breakpoints
   *   Array of breakpoint values, whereas every breakpoint value is an
   *   associative array with the following keys:
   *   - breakpoint_id: ID of the breakpoint in the breakpoints definition.
   *   - breakpoint: Replacement value for the {breakpoint} token.
   *   - value: Replacement value for the {value} token.
   *
   * @return array
   *   Array of generated classes.
   */
  protected function getClasses(array $breakpoints) {
    $pattern = $this->getSetting('pattern');
    $classes = [];
    foreach ($breakpoints as $value) {
      $class = str_replace('{breakpoint}', $value['breakpoint'], $pattern);
      $class = str_replace('{value}', $value['value'], $class);
      $class = Html::getClass($class);
      $classes[] = $class;
    }
    return $classes;
  }

}

src/Plugin/DataType/ResponsiveClassClassesData.php

/**
 * A computed property for generated CSS classes.
 *
 * @DataType(
 *   id = "responsive_class_classes",
 *   label = @Translation("Responsive Class Classes"),
 *   description = @Translation("Generated classes of the responsive class field."),
 * )
 */
class ResponsiveClassClassesData extends TypedData {

  /**
   * Array of generated classes.
   *
   * @var array
   */
  protected $classes;

  /**
   * {@inheritdoc}
   */
  public function getValue() {
    return isset($this->classes) ? $this->classes : [];
  }

  /**
   * {@inheritdoc}
   */
  public function setValue($value, $notify = TRUE) {
    $this->classes = $value;

    // Notify the parent of any changes.
    if ($notify && isset($this->parent)) {
      $this->parent->onChange($this->name);
    }
  }

  /**
   * {@inheritdoc}
   */
  public function getString() {
    return implode(' ', $this->getValue());
  }

}

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.