Commit bfa7269b authored by Justin Toupin's avatar Justin Toupin
Browse files

Issue #3260905: Layout Paragraphs Does Not Respect Cardinality

parent 025214fe
Loading
Loading
Loading
Loading
+4 −7
Original line number Diff line number Diff line
@@ -10,7 +10,8 @@
   * @param {Object} settings
   *   The settings object.
   */
  function attachUiElements($container, id, settings) {
  function attachUiElements($container, settings) {
    const id = $container[0].id;
    const lpbBuilderSettings = settings.lpBuilder || {};
    const uiElements = lpbBuilderSettings.uiElements || {};
    const containerUiElements = uiElements[id] || [];
@@ -441,14 +442,10 @@
  Drupal.behaviors.layoutParagraphsBuilder = {
    attach: function attach(context, settings) {
      // Add UI elements to the builder, each component, and each region.
      [`${idAttr}`, 'data-uuid', 'data-region-uuid'].forEach((attr) => {
        $(`[${attr}]`)
          .not('.lpb-formatter')
          .not('.has-components')
      $('[data-has-js-ui-element]')
        .once('lpb-ui-elements')
        .each((i, el) => {
            attachUiElements($(el), el.getAttribute(attr), settings);
          });
          attachUiElements($(el), settings);
        });
      // Listen to relevant events and update UI.
      const events = [
+4 −5
Original line number Diff line number Diff line
@@ -20,7 +20,8 @@ function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; }
(function ($, Drupal, debounce, dragula) {
  var idAttr = 'data-lpb-id';

  function attachUiElements($container, id, settings) {
  function attachUiElements($container, settings) {
    var id = $container[0].id;
    var lpbBuilderSettings = settings.lpBuilder || {};
    var uiElements = lpbBuilderSettings.uiElements || {};
    var containerUiElements = uiElements[id] || [];
@@ -354,10 +355,8 @@ function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; }

  Drupal.behaviors.layoutParagraphsBuilder = {
    attach: function attach(context, settings) {
      ["".concat(idAttr), 'data-uuid', 'data-region-uuid'].forEach(function (attr) {
        $("[".concat(attr, "]")).not('.lpb-formatter').not('.has-components').once('lpb-ui-elements').each(function (i, el) {
          attachUiElements($(el), el.getAttribute(attr), settings);
        });
      $('[data-has-js-ui-element]').once('lpb-ui-elements').each(function (i, el) {
        attachUiElements($(el), settings);
      });
      var events = ['lpb-builder:init.lpb', 'lpb-component:insert.lpb', 'lpb-component:update.lpb', 'lpb-component:move.lpb', 'lpb-component:drop.lpb', 'lpb-component:delete.lpb'].join(' ');
      $('[data-lpb-id]').once('lpb-events').on(events, function (e) {
+52 −24
Original line number Diff line number Diff line
@@ -5,14 +5,15 @@ namespace Drupal\layout_paragraphs\Element;
use Drupal\Core\Url;
use Drupal\Core\Render\Markup;
use Drupal\Core\Render\Renderer;
use Drupal\Component\Utility\Html;
use Drupal\Component\Serialization\Json;
use Drupal\paragraphs\ParagraphInterface;
use Drupal\Core\Access\AccessResultAllowed;
use Drupal\Core\Layout\LayoutPluginManager;
use Drupal\Core\Entity\EntityTypeBundleInfo;
use Drupal\layout_paragraphs\Utility\Dialog;
use Drupal\Core\Access\AccessResultForbidden;
use Drupal\Core\Render\Element\RenderElement;
use Drupal\layout_paragraphs\Utility\Dialog;
use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\layout_paragraphs\LayoutParagraphsSection;
@@ -223,16 +224,26 @@ class LayoutParagraphsBuilder extends RenderElement implements ContainerFactoryP
        'lp-builder',
        'lp-builder-' . $this->layoutParagraphsLayout->id(),
      ],
      'id' => Html::getUniqueId($this->layoutParagraphsLayout->id()),
      'data-lpb-id' => $this->layoutParagraphsLayout->id(),
    ];
    $element['#attached']['library'] = ['layout_paragraphs/builder'];
    $element['#attached']['drupalSettings']['lpBuilder'][$this->layoutParagraphsLayout->id()] = $this->layoutParagraphsLayout->getSettings();
    $element['#is_empty'] = $this->layoutParagraphsLayout->isEmpty();
    $element['#empty_message'] = $this->layoutParagraphsLayout->getSetting('empty_message', $this->t('Start adding content.'));
    $element['#root_components'] = [];
    foreach ($this->layoutParagraphsLayout->getRootComponents() as $component) {
      /** @var \Drupal\layout_paragraphs\LayoutParagraphsComponent $component */
      $uuid = $component->getEntity()->uuid();
      $element['#root_components'][$uuid] =& $element['#components'][$uuid];
    }
    if (count($element['#root_components'])) {
      $element['#attributes']['class'][] = 'has-components';
    }
    else {
      if ($this->layoutParagraphsLayout->getSetting('require_layouts', FALSE)) {
        $this->addJsUiElement(
          $element,
        $this->layoutParagraphsLayout->id(),
          $this->doRender($this->insertSectionButton(['layout_paragraphs_layout' => $this->layoutParagraphsLayout->id()], [], 0, ['center'])),
          'insert'
        );
@@ -240,19 +251,10 @@ class LayoutParagraphsBuilder extends RenderElement implements ContainerFactoryP
      else {
        $this->addJsUiElement(
          $element,
        $this->layoutParagraphsLayout->id(),
          $this->doRender($this->insertComponentButton(['layout_paragraphs_layout' => $this->layoutParagraphsLayout->id()], [], 0, ['center'])),
          'insert'
        );
      }
    $element['#root_components'] = [];
    foreach ($this->layoutParagraphsLayout->getRootComponents() as $component) {
      /** @var \Drupal\layout_paragraphs\LayoutParagraphsComponent $component */
      $uuid = $component->getEntity()->uuid();
      $element['#root_components'][$uuid] =& $element['#components'][$uuid];
    }
    if (count($element['#root_components'])) {
      $element['#attributes']['class'][] = 'has-components';
    }
    return $element;
  }
@@ -279,6 +281,7 @@ class LayoutParagraphsBuilder extends RenderElement implements ContainerFactoryP
    $build['#attributes']['data-type'] = $entity->bundle();
    $build['#attributes']['data-id'] = $entity->id();
    $build['#attributes']['class'][] = 'js-lpb-component';
    $build['#attributes']['id'] = Html::getUniqueId($entity->id());
    $build['#layout_paragraphs_component'] = TRUE;
    if ($entity->isNew()) {
      $build['#attributes']['class'][] = 'is_new';
@@ -307,23 +310,22 @@ class LayoutParagraphsBuilder extends RenderElement implements ContainerFactoryP
      '#uuid' => $entity->uuid(),
      '#layout_paragraphs_layout' => $this->layoutParagraphsLayout,
      '#edit_access' => $this->editAccess($entity),
      '#duplicate_access' => $this->createAccess(),
      '#duplicate_access' => $this->createAccess() && $this->checkCardinality(),
      '#delete_access' => $this->deleteAccess($entity),
    ];
    $this->addJsUiElement($build, $entity->uuid(), $this->doRender($controls), 'controls', 'prepend');
    $build['#attached']['drupalSettings']['lpBuilder']['uiElements'][$entity->uuid()] = [];
    $this->addJsUiElement($build, $this->doRender($controls), 'controls', 'prepend');

    if ($this->createAccess()) {
    if ($this->createAccess() && $this->checkCardinality()) {
      if (!$component->getParentUuid() && $this->layoutParagraphsLayout->getSetting('require_layouts')) {
        $this->addJsUiElement(
          $build,
          $entity->uuid(),
          $this->doRender($this->insertSectionButton($url_params, $query_params + ['placement' => 'before'], -10000, ['before'])),
          'insert_before',
          'prepend'
        );
        $this->addJsUiElement(
          $build,
          $entity->uuid(),
          $this->doRender($this->insertSectionButton($url_params, $query_params + ['placement' => 'after'], 10000, ['after'])),
          'insert_after',
          'append'
@@ -332,14 +334,12 @@ class LayoutParagraphsBuilder extends RenderElement implements ContainerFactoryP
      else {
        $this->addJsUiElement(
          $build,
          $entity->uuid(),
          $this->doRender($this->insertComponentButton($url_params, $query_params + ['placement' => 'before'], -10000, ['before'])),
          'insert_before',
          'prepend'
        );
        $this->addJsUiElement(
          $build,
          $entity->uuid(),
          $this->doRender($this->insertComponentButton($url_params, $query_params + ['placement' => 'after'], -10000, ['after'])),
          'insert_after',
          'append'
@@ -371,12 +371,12 @@ class LayoutParagraphsBuilder extends RenderElement implements ContainerFactoryP
            ],
            'data-region' => $region_name,
            'data-region-uuid' => $entity->uuid() . '-' . $region_name,
            'id' => Html::getUniqueId($entity->uuid() . '-' . $region_name),
          ],
        ];
        if ($this->createAccess()) {
        if ($this->createAccess() && $this->checkCardinality()) {
          $this->addJsUiElement(
            $build,
            $entity->uuid() . '-' . $region_name,
            $build['regions'][$region_name],
            $this->doRender($this->insertComponentButton($url_params, $query_params, 10000, ['center'])),
            'insert'
          );
@@ -681,8 +681,6 @@ class LayoutParagraphsBuilder extends RenderElement implements ContainerFactoryP
   *
   * @param array $build
   *   The build array to attach JS settings to.
   * @param string $id
   *   The element container's id.
   * @param \Drupal\Core\Render\Markup $element
   *   The UI element.
   * @param string $key
@@ -690,7 +688,9 @@ class LayoutParagraphsBuilder extends RenderElement implements ContainerFactoryP
   * @param string $method
   *   The javascript method to use to attach $element to its container.
   */
  public function addJsUiElement(array &$build, string $id, Markup $element, string $key, string $method = 'append') {
  public function addJsUiElement(array &$build, Markup $element, string $key, string $method = 'append') {
    $id = $build['#attributes']['id'];
    $build['#attributes']['data-has-js-ui-element'] = TRUE;
    $build['#attached']['drupalSettings']['lpBuilder']['uiElements'][$id][$key] = [
      'element' => $element,
      'method' => $method,
@@ -710,4 +710,32 @@ class LayoutParagraphsBuilder extends RenderElement implements ContainerFactoryP
    return $this->renderer->render($render_array);
  }

  /**
   * Checks if adding a component would exceed the field's cardinality limit.
   *
   * @return bool
   *   True if a compoment can be added without exceeding cardinality.
   */
  protected function checkCardinality() {
    $cardinality = $this->getCardinality();
    if ($cardinality > 0) {
      $count = $this->layoutParagraphsLayout->getParagraphsReferenceField()->count();
      return $cardinality > $count;
    }
    return TRUE;
  }

  /**
   * Gets the cardinality field setting for a Layout Paragraphs reference field.
   *
   * @return int
   *   The cardinality setting.
   */
  protected function getCardinality() {
    $field_name = $this->layoutParagraphsLayout->getFieldName();
    $field_config = $this->layoutParagraphsLayout->getEntity()->{$field_name}->getFieldDefinition();
    $field_definition = $field_config->getFieldStorageDefinition();
    return $field_definition->getCardinality();
  }

}
+47 −5
Original line number Diff line number Diff line
@@ -15,22 +15,64 @@ class LayoutParagraphsUpdateLayoutSubscriber implements EventSubscriberInterface
   */
  public static function getSubscribedEvents() {
    return [
      LayoutParagraphsUpdateLayoutEvent::EVENT_NAME => 'compareLayouts',
      LayoutParagraphsUpdateLayoutEvent::EVENT_NAME => 'layoutUpdated',
    ];
  }

  /**
   * Restricts available types based on settings in layout.
   * Determines if a Layout Paragraphs Builder UI needs to be refreshed.
   *
   * Some interactions will create conditions where the entire builder UI needs
   * to be refreshed, rather than simply returning a single new or edited
   * component. For example: when removing the only component from a layout,
   * the layout should be refreshed to show the correct ui/messaging for an
   * empty container.
   *
   * @param \Drupal\layout_paragraphs\Event\LayoutParagraphsUpdateLayoutEvent $event
   *   The allowed types event.
   *   The event.
   */
  public function compareLayouts(LayoutParagraphsUpdateLayoutEvent $event) {
  public function layoutUpdated(LayoutParagraphsUpdateLayoutEvent $event) {
    $event->needsRefresh = $this->compareEmptyState($event) || $this->compareMaximumReached($event);
  }

  /**
   * Compares the empty state of the original and updated layout.
   *
   * If the original layout was empty and the updated layout is not, or visa
   * versa, the entire layout builder ui needs to be refreshed.
   *
   * @param \Drupal\layout_paragraphs\Event\LayoutParagraphsUpdateLayoutEvent $event
   *   The event.
   */
  public function compareEmptyState(LayoutParagraphsUpdateLayoutEvent $event) {
    $original = $event->getOriginalLayout()->getParagraphsReferenceField();
    $layout = $event->getUpdatedLayout()->getParagraphsReferenceField();
    return $original->isEmpty() != $layout->isEmpty();
  }

  /**
   * Compares count == cardinality of the original and updated layouts.
   *
   * @param \Drupal\layout_paragraphs\Event\LayoutParagraphsUpdateLayoutEvent $event
   *   The event.
   *
   * @return bool
   *   True if the count == cardinality limit has changed.
   */
  public function compareMaximumReached(LayoutParagraphsUpdateLayoutEvent $event) {

    $original_count = $event->getOriginalLayout()->getParagraphsReferenceField()->count();
    $updated_count = $event->getUpdatedLayout()->getParagraphsReferenceField()->count();
    $cardinality = $event->getUpdatedLayout()
      ->getParagraphsReferenceField()
      ->getFieldDefinition()
      ->getFieldStorageDefinition()
      ->getCardinality();

    $original_is_max = $cardinality > 0 && $cardinality <= $original_count;
    $updated_is_max = $cardinality > 0 && $cardinality <= $updated_count;

    $event->needsRefresh = ($original->isEmpty() != $layout->isEmpty());
    return $original_is_max != $updated_is_max;
  }

}
+70 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\Tests\layout_paragraphs\FunctionalJavascript;

/**
 * Tests cardinality settings for a Layout Paragraphs field widget.
 *
 * @group layout_paragraphs
 */
class CardinalityTest extends BuilderTestBase {

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();
    $this->loginWithPermissions([
      'administer site configuration',
      'administer node fields',
      'administer node display',
      'administer paragraphs types',
    ]);
    $this->drupalGet('admin/structure/types/manage/page/fields/node.page.field_content/storage');
    $this->submitForm([
      'cardinality' => 'number',
      'cardinality_number' => 2,
    ], 'Save field settings');
  }

  /**
   * Tests cardinality settings for a Layout Paragraphs field widget.
   */
  public function testCardinality() {

    $this->loginWithPermissions([
      'create page content',
      'edit own page content',
    ]);

    $this->drupalGet('node/add/page');
    $page = $this->getSession()->getPage();

    // Add a three-column section.
    $this->addSectionComponent(2, '.lpb-btn--add');

    // Cardinality is set to 2. We should still have (+) buttons.
    $this->assertSession()->elementExists('css', '.layout__region--first .lpb-btn--add');
    $this->htmlOutput($this->getSession()->getPage()->getHtml());

    // Add a text component.
    $this->addTextComponent('Some arbitrary text', '.layout__region--first .lpb-btn--add');

    // Maximum number has been reached. There should be no more (+) buttons.
    $this->assertSession()->elementNotExists('css', '.layout__region--first .lpb-btn--add');
    $this->htmlOutput($this->getSession()->getPage()->getHtml());

    // Remove a component.
    $button = $page->find('css', '.layout__region--first a.lpb-delete');
    $button->click();
    $this->assertSession()->assertWaitOnAjaxRequest();
    $button = $page->find('css', 'button.lpb-btn--confirm-delete');
    $button->click();
    $this->assertSession()->assertWaitOnAjaxRequest();

    // We no longer have the maximum allowed items, and should have (+) buttons.
    $this->assertSession()->elementExists('css', '.layout__region--first .lpb-btn--add');
    $this->htmlOutput($this->getSession()->getPage()->getHtml());
  }

}
+1 −1

File changed.

Contains only whitespace changes.