New action/condition: entity diff

Created on 7 April 2023, over 1 year ago
Updated 13 July 2023, about 1 year ago

Problem/Motivation

To know if an entity got changed and/or which fields of an entity got changed, would help a lot in many use cases.

Proposed resolution

The following 2 new plugins should work based on a content entity which contains a original property, e.g. in the pre-save entity event:

  • Provide a new action that returns a list of field names that got changed
  • Provide a new condition that return TRUE if at least one field got changed

An extra bonus would be if those two plugins also worked in the form context, i.e. in the form validate event.

Remaining tasks

  • There is a todo comment in the MR to enhance the access control
  • Add a condition plugin which verifies if either field has changed, or a single field provided in the include field
  • Move common code between action and condition plugin into a trait
  • Write tests
✨ Feature request
Status

Fixed

Version

1.2

Component

Code

Created by

πŸ‡©πŸ‡ͺGermany jurgenhaas Gottmadingen

Live updates comments and jobs are added and updated live.
Sign in to follow issues

Comments & Activities

  • Issue created by @jurgenhaas
  • πŸ‡ΊπŸ‡ΈUnited States freelock Seattle

    Hi,

    Just saw this issue referenced on an old Slack thread -- I did end up creating a custom action plugin for our clients, so this can be the start of a more generic one.

    With this implementation, you can get either a list of fields that differ, or a list of the different values with the field names as keys. You can pass in two entirely different entities, or an entity and its :original.

    We are only using this to get a list of field names, I have not tested the value option.

    
    namespace Drupal\my_module\Plugin\Action;
    
    use Drupal\Component\Utility\DiffArray;
    use Drupal\Core\Entity\ContentEntityInterface;
    use Drupal\Core\Form\FormStateInterface;
    use Drupal\Core\Session\AccountInterface;
    use Drupal\eca\Plugin\Action\ConfigurableActionBase;
    
    /**
     * Provides a Load Diffs action.
     *
     * @Action(
     *   id = "my_module_load_diffs",
     *   label = @Translation("Load Diffs"),
     *   description = @Translation("Compare 2 entities and return a list of fields that differ"),
     *   type = "entity"
     * )
     *
     */
    class LoadDiffs extends ConfigurableActionBase {
    
      /**
       * {@inheritDoc}
       */
      public function defaultConfiguration(): array
      {
        return [
          'token_name' => '',
          'compare' => null,
          'return_values' => FALSE,
          'exclude_fields' => [],
          'include_fields' => [],
        ] + parent::defaultConfiguration();
      }
    
      /**
       * {@inheritdoc}
       */
      public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
        $form['token_name'] = [
          '#type' => 'textfield',
          '#title' => $this->t('Name of token'),
          '#default_value' => $this->configuration['token_name'],
          '#description' => $this->t('Provide the name of a token that holds the new list.'),
          '#weight' => -60,
        ];
        $form['compare'] = [
          '#type' => 'textfield',
          '#title' => $this->t('Compare'),
          '#description' => $this->t('Provide the name of a token that holds the original entity.'),
          '#default_value' => $this->configuration['compare'],
          '#weight' => 30,
        ];
        $form['return_values'] = [
          '#type' => 'checkbox',
          '#title' => $this->t('Return values'),
          '#default_value' => $this->configuration['return_values'],
          '#description' => $this->t('If checked, the list will return values. If unchecked, it will only return the different machine names of changed fields.'),
        ];
        $form['exclude_fields'] = [
          '#type' => 'textarea',
          '#title' => 'Exclude fields',
          '#description' => $this->t('List field machine names to remove from difference/ignore'),
          '#default_value' => implode("\n", $this->configuration['exclude_fields']),
          '#weight' => 40,
        ];
        $form['include_fields'] = [
          '#type' => 'textarea',
          '#title' => 'Include fields',
          '#description' => $this->t('List field machine names to include in difference -- all others will be ignored.'),
          '#default_value' => implode("\n", $this->configuration['include_fields']),
          '#weight' => 40,
        ];
        return $form;
      }
    
      /**
       * {@inheritdoc}
       */
      public function submitConfigurationForm(array &$form, FormStateInterface $form_state): void
      {
        $this->configuration['token_name'] = $form_state->getValue('token_name');
        $this->configuration['compare'] = $form_state->getValue('compare');
        $this->configuration['return_values'] = $form_state->getValue('return_values');
        $this->configuration['exclude_fields'] = explode("\n", $form_state->getValue('exclude_fields'));
        $this->configuration['include_fields'] = explode("\n", $form_state->getValue('include_fields'));
        parent::submitConfigurationForm($form, $form_state);
      }
    
      /**
       * {@inheritdoc}
       */
      public function access($object, AccountInterface $account = NULL, $return_as_object = FALSE) {
        /** @var ContentEntityInterface $object */
        $access = $object->access('view', $account, TRUE);
        return $return_as_object ? $access : $access->isAllowed();
      }
    
      /**
       * {@inheritdoc}
       */
      public function execute($entity = NULL) {
        $compare = $this->tokenServices->getTokenData($this->configuration['compare']);
        $diff = DiffArray::diffAssocRecursive($entity->toArray(), $compare->toArray());
        $exclude_fields = $this->configuration['exclude_fields'];
        if (count($exclude_fields)) {
          foreach ($exclude_fields as $field) {
            unset($diff[$field]);
          }
        }
        $include_fields = $this->configuration['include_fields'];
        if (count($include_fields)) {
          $included = [];
          foreach ($include_fields as $field) {
            if (isset ($diff[$field])) {
              $included[$field] = $diff[$field];
            }
          }
          $diff = $included;
        }
        if (!$this->configuration['return_values']) {
          $diff = array_keys($diff);
        }
        $this->tokenServices->addTokenData($this->configuration['token_name'], $diff);
      }
    
    }
    
  • πŸ‡©πŸ‡ͺGermany jurgenhaas Gottmadingen

    This is nice, thanks a lot @freelock, will turn this into an MR so that we can build on top of this with further details and some tests. Amazing input!!

  • Open in Jenkins β†’ Open on Drupal.org β†’
    Core: 10.0.7 + Environment: PHP 8.1 & MySQL 8
    last update over 1 year ago
    287 pass
  • @jurgenhaas opened merge request.
  • Status changed to Needs work over 1 year ago
  • πŸ‡©πŸ‡ͺGermany jurgenhaas Gottmadingen

    Moved the code from @freelock into an issue fork and started an MR. Also updated the OP to highlight the remaining tasks.

  • πŸ‡©πŸ‡ͺGermany jurgenhaas Gottmadingen

    Please see the discussion at πŸ› Condition: field value changed - not working for booleans Fixed where we realized, that comparing field values may not be as straight forward as we thought. To compare entities and their field values, we may even re-use code from that condition plugin, which is discussed there.

  • πŸ‡ΊπŸ‡ΈUnited States freelock Seattle

    Well, our custom plugin does seem to be working fine for us --

    We're using it like this:

    1. Update Content Entity event
    2. Entity: Load - token: originalentity, Current scope, latest revision no, unchanged values yes, entity: entity
    3. Load Diffs - token: diffs, return values no, entity: entity, compare: originalentity, exclude fields: changed, a couple others
    4. Compare number of list items - token: diffs, operator: greater than, second value: 0
    5. Tamper: implode - data: [diffs], glue: %n, result_token: diff_text

    We're then dropping that in an email notification. It provides a list of fields that have actual changes between the unsaved and saved version of the entity.

    We're using it for both user entities and nodes -- looks like one of our node ones is using a list of include fields instead of exclude.

    I have not tried to use the values (the return values option), just the keys.

  • πŸ‡ΊπŸ‡ΈUnited States freelock Seattle

    Maybe Drupal\Component\Utility\DiffArray::diffAssocRecursive() is sorting out these issues internally? Or maybe the $entity->toArray() calls are putting them in the same format?

  • πŸ‡©πŸ‡ͺGermany jurgenhaas Gottmadingen

    Do you have boolean fields or entity references in your entities that you compare?

  • πŸ‡ΊπŸ‡ΈUnited States freelock Seattle

    Yes...

  • Assigned to jurgenhaas
  • πŸ‡©πŸ‡ͺGermany jurgenhaas Gottmadingen

    OK, then Drupal core loads entities differently, otherwise the problem in the other issue won't be possible. The diff functions being used here won't be able to resolve the different data structure especially for entity references as shown in this comment πŸ› Condition: field value changed - not working for booleans Fixed .

  • Open in Jenkins β†’ Open on Drupal.org β†’
    Core: 10.0.7 + Environment: PHP 8.1 & MySQL 8
    last update about 1 year ago
    289 pass
  • Issue was unassigned.
  • Status changed to Needs review about 1 year ago
  • πŸ‡©πŸ‡ͺGermany jurgenhaas Gottmadingen

    I have now implemented the action and the condition plugin. Both are ready for review. Afterwards, we then also need tests - anyone up for it?

  • Assigned to danielspeicher
  • Status changed to Needs work about 1 year ago
  • πŸ‡©πŸ‡ͺGermany danielspeicher Steisslingen

    I will add some tests.

  • Open in Jenkins β†’ Open on Drupal.org β†’
    Core: 10.0.7 + Environment: PHP 8.1 & MySQL 8
    last update about 1 year ago
    294 pass
  • Open in Jenkins β†’ Open on Drupal.org β†’
    Core: 10.0.7 + Environment: PHP 8.1 & MySQL 8
    last update about 1 year ago
    295 pass
  • πŸ‡©πŸ‡ͺGermany jurgenhaas Gottmadingen

    Tagginng this for Dev Days next week.

  • Open in Jenkins β†’ Open on Drupal.org β†’
    Core: 10.0.7 + Environment: PHP 8.1 & MySQL 8
    last update about 1 year ago
    296 pass
  • Open in Jenkins β†’ Open on Drupal.org β†’
    Core: 10.0.7 + Environment: PHP 8.1 & MySQL 8
    last update about 1 year ago
    296 pass
  • Status changed to Needs review about 1 year ago
  • πŸ‡©πŸ‡ͺGermany danielspeicher Steisslingen

    I have added tests for both plugins.

  • πŸ‡©πŸ‡ͺGermany danielspeicher Steisslingen
  • πŸ‡©πŸ‡ͺGermany danielspeicher Steisslingen
  • Issue was unassigned.
  • πŸ‡©πŸ‡ͺGermany danielspeicher Steisslingen
  • Status changed to RTBC about 1 year ago
  • πŸ‡©πŸ‡ͺGermany jurgenhaas Gottmadingen

    Ah, very nice.

  • Open in Jenkins β†’ Open on Drupal.org β†’
    Core: 10.0.7 + Environment: PHP 8.1 & MySQL 8
    last update about 1 year ago
    296 pass
  • Status changed to Fixed about 1 year ago
  • πŸ‡©πŸ‡ͺGermany jurgenhaas Gottmadingen
  • Automatically closed - issue fixed for 2 weeks with no activity.

Production build 0.71.5 2024