Commit e2a55d9a authored by Bas Vredeling's avatar Bas Vredeling
Browse files

Issue #3099424 by basvredeling, chant9, tom.moffett, mb_lewis, PCate,...

Issue #3099424 by basvredeling, chant9, tom.moffett, mb_lewis, PCate, CarlHinton, sim_1, jcromett, frob: Re-ordering paragraphs with admin titles on node edit screen changes order in page layout
parent f8349d30
Loading
Loading
Loading
Loading
+28 −0
Original line number Diff line number Diff line
@@ -6,6 +6,7 @@
 */

use Drupal\Core\Entity\EntityFormInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\Core\Form\FormStateInterface;
@@ -172,3 +173,30 @@ function _paragraph_blocks_process_widget_form(array &$element, FormStateInterfa
    }
  }
}

/**
 * Implements hook_entity_presave().
 */
function paragraph_blocks_entity_presave(EntityInterface $entity) {
  if (method_exists($entity, 'isSyncing') && $entity->isSyncing()) {
    // For the case where we have Workspace being published, doing the logic
    // beyond this breaks the appearance of paragraphs in the page when it is
    // deployed to the Live workspace. Workspaces sets that it is syncing, and
    // we'll use that to prevent proceeding and causing our paragraphs to have
    // incorrect order in the layout.
    //
    // One thing I'm uncertain of is if using this flag causes issues in other
    // cases where the syncing flag might be used. Info on sync:
    // https://www.drupal.org/project/drupal/issues/2985297
    //
    return;
  }

  // Check if entity is using layout builder.
  if (method_exists($entity, 'hasField') && $entity->hasField('layout_builder__layout')) {
    /** @var \Drupal\paragraph_blocks\ParagraphBlocksEntityPresaveHelper $presave_helper */
    $presave_helper = \Drupal::service('paragraph_blocks.entity_presave_helper');
    $presave_helper->setEntity($entity);
    $presave_helper->updateLayoutBuilderConfiguration();
  }
}
+3 −0
Original line number Diff line number Diff line
@@ -9,3 +9,6 @@ services:
  paragraph_blocks.labeller:
    class: Drupal\paragraph_blocks\ParagraphBlocksLabeller
    arguments: ['@paragraph_blocks.entity_manager', '@entity_field.manager']
  paragraph_blocks.entity_presave_helper:
    class: Drupal\paragraph_blocks\ParagraphBlocksEntityPresaveHelper
    arguments: ['@tempstore.private']
+368 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\paragraph_blocks;

use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\TempStore\PrivateTempStore;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use Drupal\Core\TempStore\TempStoreException;

/**
 * ParagraphBlocksEntityPresaveHelper service.
 */
class ParagraphBlocksEntityPresaveHelper {

  /**
   * The private temp store factory service.
   *
   * @var \Drupal\Core\TempStore\PrivateTempStoreFactory
   */
  protected PrivateTempStoreFactory $tempStoreFactory;

  /**
   * The paragraph_blocks_entity_presave temp store.
   *
   * @var ?\Drupal\Core\TempStore\PrivateTempStore
   */
  private ?PrivateTempStore $tempStore;

  /**
   * The key identifying the temp store.
   */
  private string $tempStoreKey;

  /**
   * The entity to use the presave helper on.
   */
  private EntityInterface $entity;

  /**
   * Entity's layout builder layout configuration.
   *
   * @var
   */
  private $layout;

  /**
   * Constructs a ParagraphBlocksEntityPresaveHelper object.
   *
   * @param \Drupal\Core\TempStore\PrivateTempStoreFactory $temp_store_factory
   *   The temp store service.
   */
  public function __construct(PrivateTempStoreFactory $temp_store_factory) {
    $this->tempStoreFactory = $temp_store_factory;
    $this->tempStore = $this->tempStoreFactory->get('paragraph_blocks_entity_presave');
  }

  /**
   * Getter for the entity property.
   */
  public function getEntity(): ?EntityInterface {
    return $this->entity;
  }

  /**
   * Setter for the entity property.
   */
  public function setEntity(EntityInterface $entity): void {
    $this->entity = $entity;
    $this->tempStoreKey = $entity->bundle() . '.' . $entity->id() . '.' . $entity->language()
        ->getId();
    $this->layout = $this->entity->get('layout_builder__layout');
  }

  /**
   * Update the layout builder configuration of an entity.
   *
   * This is necessary to prevent a broken layout when the paragraph references
   * change order or are deleted.
   *
   * @see: https://www.drupal.org/project/paragraph_blocks/issues/3099424
   *
   * @return void
   */
  public function updateLayoutBuilderConfiguration(): void {
    // Use temp store to ignore delta changes if entity is being created/cloned.
    $new_paragraph_ids = $this->getTempStoreValue() ?? [];

    if (is_null($this->getEntity())) {
      \Drupal::logger('paragraph_blocks')
        ->error('Could not perform presave helper method %method because entity was not set.', ['%method' => __FUNCTION__]);
      return;
    }

    if (!$this->entity->isNew()) {
      $sections = $this->layout->getIterator()->getArrayCopy();

      // Loop through paragraph blocks fields.
      foreach ($this->getParagraphBlocksFields() as $field) {
        // Collect the paragraph deltas before and after the entity is saved.
        $deltas_original = $this->getDeltasOriginal($field);
        $deltas = $this->getDeltas($field);

        // Collate delta changes.
        $deltas_reordered = $this->determineDeltasReordered($deltas_original, $deltas);

        // Collate any paragraphs that have been deleted.
        $deltas_deleted = $this->determineDeltasDeleted($deltas_original, $deltas, $new_paragraph_ids);

        // Check if any deltas have been changed or deleted.
        $delta_updates = [];
        $delta_deletes = [];
        if ($deltas_reordered || $deltas_deleted) {
          // Loop through the layout sections.
          foreach ($sections as $section_index => $section) {
            // Loop through each component in the section.
            foreach ($section->getValue()['section']->getComponents() as $component_index => $component) {
              $configuration = $section->getValue()['section']->getComponents()[$component_index]->get('configuration');
              $component_uuid = $section->getValue()['section']->getComponents()[$component_index]->getUuid();
              $this->prepareLayoutBuilderDeltaUpdates($delta_updates, $deltas_reordered, $field, $configuration, $section_index, $component_index);
              $this->prepareLayoutBuilderDeltaDeletes($delta_deletes, $deltas_deleted, $field, $configuration, $section_index, $component_index, $component_uuid);
            }
          }
        }

        // Loop through the paragraph delta updates applying them.
        $this->performLayoutBuilderDeltaUpdates($delta_updates);

        // Loop through the paragraph delta deletes removing them.
        $this->performLayoutBuilderDeltaDeletes($delta_deletes);
      }
    }

    // Collect paragraph ids to suppress deleting if entity is being cloned.
    $new_paragraph_ids = [];
    if ($this->entity->isNew()) {
      // Collect the paragraph ids of the new entity.
      foreach ($this->entity->getFields() as $fieldKey => $field) {
        if (method_exists($field->getFieldDefinition(), 'getThirdPartySetting') && $field->getFieldDefinition()
            ->getThirdPartySetting('paragraph_blocks', 'status')) {
          foreach ($this->entity->get($fieldKey)
                     ->getIterator() as $delta => $item) {
            $new_paragraph_ids[] = $item->getValue()['target_id'];
          }
        }
      }
    }

    try {
      $this->setTempStoreValue($new_paragraph_ids);
    }
    catch (TempStoreException $e) {
      \Drupal::logger('paragraph_blocks')->error($e->getMessage());
    }
  }

  /**
   * Get the value of the entity's paragraph_blocks_entity_presave temp store.
   *
   * @return mixed
   *   The data associated with the key, or NULL if the key does not exist.
   */
  private function getTempStoreValue(): mixed {
    return $this->tempStore->get($this->tempStoreKey);
  }

  /**
   * Set the value of the entity's paragraph_blocks_entity_presave temp store.
   *
   * @param mixed $value
   *   The data to store.
   *
   * @return void
   *
   * @throws \Drupal\Core\TempStore\TempStoreException
   */
  private function setTempStoreValue(mixed $value): void {
    $this->tempStore->set($this->tempStoreKey, $value);
  }

  /**
   * Collect paragraph reference fields using paragraph blocks.
   *
   * @return array
   */
  private function getParagraphBlocksFields(): array {
    $fields = [];
    foreach ($this->entity->getFields() as $key => $field) {
      if (method_exists($field->getFieldDefinition(), 'getThirdPartySetting') && $field->getFieldDefinition()
          ->getThirdPartySetting('paragraph_blocks', 'status')) {
        $fields[] = $key;
      }
    }
    return $fields;
  }

  /**
   * Get a list of field deltas before the save action.
   *
   * @param $field
   *
   * @return array
   */
  private function getDeltasOriginal($field): array {
    $deltas = [];
    foreach ($this->entity->original->get($field)
               ->getIterator() as $delta => $item) {
      $deltas[$item->getValue()['target_id']] = $delta;
    }
    return $deltas;
  }

  /**
   * Get a list of field deltas after the save action.
   *
   * @param mixed $field
   *
   * @return array
   */
  private function getDeltas(mixed $field): array {
    $deltas = [];
    foreach ($this->entity->get($field)->getIterator() as $delta => $item) {
      $deltas[$item->getValue()['target_id']] = $delta;
    }
    return $deltas;
  }

  /**
   * Determine reordered field deltas.
   *
   * Compare field deltas before and after the save action to determine which
   * paragraphs have been reordered.
   *
   * @param array $deltas_original
   * @param array $deltas
   *
   * @return array
   */
  private function determineDeltasReordered(array $deltas_original, array $deltas): array {
    $reorders = [];
    foreach ($deltas as $paragraphEntityId => $delta) {
      if (isset($deltas_original[$paragraphEntityId]) && $delta != $deltas_original[$paragraphEntityId]) {
        $reorders[$delta] = $deltas_original[$paragraphEntityId];
      }
    }
    return $reorders;
  }

  /**
   * Determine deleted field deltas.
   *
   * Compare field deltas before and after the save action to determine which
   * paragraphs have been reordered.
   *
   * @param array $deltas_original
   * @param array $deltas
   * @param array $new_paragraph_ids
   *
   * @return array
   */
  private function determineDeltasDeleted(array $deltas_original, array $deltas, array $new_paragraph_ids): array {
    $deltas_deleted = [];
    foreach ($deltas_original as $paragraphEntityId => $delta) {
      if (!isset($deltas[$paragraphEntityId]) && !in_array($paragraphEntityId, $new_paragraph_ids)) {
        $deltas_deleted[] = $delta;
      }
    }
    return $deltas_deleted;
  }

  /**
   * Loop through delta reorders to see if section configuration needs updating.
   *
   * @param array $delta_updates
   * @param array $deltas_reordered
   * @param string $field
   * @param array $configuration
   * @param int|string $section_index
   * @param int|string $component_index
   *
   * @return void
   */
  private function prepareLayoutBuilderDeltaUpdates(array &$delta_updates, array $deltas_reordered, string $field, array $configuration, int|string $section_index, int|string $component_index): void {
    if (!empty($deltas_reordered)) {
      foreach ($deltas_reordered as $delta_to => $delta_from) {
        $delta_old = 'paragraph_field:' . $this->entity->getEntityType()
            ->id() . ':' . $field . ':' . $delta_from . ':' . $this->entity->bundle();
        $delta_new = 'paragraph_field:' . $this->entity->getEntityType()
            ->id() . ':' . $field . ':' . $delta_to . ':' . $this->entity->bundle();

        // Collect the required paragraph delta updates.
        if ($configuration['id'] == $delta_old) {
          $delta_updates[] = [
            'section_index' => $section_index,
            'component_index' => $component_index,
            'configuration_id' => $delta_new,
          ];
        }
      }
    }
  }

  /**
   * Update paragraph deltas in the layout builder configuration.
   *
   * @param array $delta_updates
   *
   * @return void
   */
  private function performLayoutBuilderDeltaUpdates(array $delta_updates): void {
    foreach ($delta_updates as ['section_index' => $section_index, 'component_index' => $component_index, 'configuration_id' => $configuration_id]) {
      $configuration = $this->layout
        ->getIterator()
        ->offsetGet($section_index)
        ->getValue()['section']->getComponents()[$component_index]->get('configuration');
      $configuration['id'] = $configuration_id;
      $this->layout
        ->getIterator()
        ->offsetGet($section_index)
        ->getValue()['section']->getComponents()[$component_index]->setConfiguration($configuration);
    }
  }

  /**
   * Loop through deletes to see if section configuration needs updating.
   *
   * @param array $delta_deletes
   * @param array $deltas_deleted
   * @param mixed $field
   * @param $configuration
   * @param int|string $section_index
   * @param int|string $component_index
   * @param string $component_uuid
   *
   * @return void
   */
  private function prepareLayoutBuilderDeltaDeletes(array &$delta_deletes, array $deltas_deleted, mixed $field, $configuration, int|string $section_index, int|string $component_index, string $component_uuid): void {
    foreach ($deltas_deleted as $delta) {
      $delta_old = 'paragraph_field:' . $this->entity->getEntityType()
          ->id() . ':' . $field . ':' . $delta . ':' . $this->entity->bundle();

      // Collect the required paragraph delta updates.
      if ($configuration['id'] == $delta_old) {
        $delta_deletes[] = [
          'section_index' => $section_index,
          'component_index' => $component_index,
          'component_uuid' => $component_uuid,
        ];
      }
    }
  }

  /**
   * Delete paragraph deltas from the layout builder configuration.
   *
   * @param array $delta_deletes
   *
   * @return void
   */
  private function performLayoutBuilderDeltaDeletes(array $delta_deletes): void {
    foreach ($delta_deletes as ['section_index' => $section_index, 'component_index' => $component_index, 'component_uuid' => $component_uuid]) {
      $this->layout
        ->getIterator()
        ->offsetGet($section_index)
        ->getValue()['section']->removeComponent($component_uuid);
    }
  }

}