Loading paragraph_blocks.module +28 −0 Original line number Diff line number Diff line Loading @@ -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; Loading Loading @@ -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(); } } paragraph_blocks.services.yml +3 −0 Original line number Diff line number Diff line Loading @@ -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'] src/ParagraphBlocksEntityPresaveHelper.php 0 → 100644 +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); } } } Loading
paragraph_blocks.module +28 −0 Original line number Diff line number Diff line Loading @@ -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; Loading Loading @@ -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(); } }
paragraph_blocks.services.yml +3 −0 Original line number Diff line number Diff line Loading @@ -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']
src/ParagraphBlocksEntityPresaveHelper.php 0 → 100644 +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); } } }