Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 89
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 92
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
ContextualFormPanel
0.00% covered (danger)
0.00%
0 / 82
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 92
0.00% covered (danger)
0.00%
0 / 12
1056
0.00% covered (danger)
0.00%
0 / 1
 buildForm
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
30
 build
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 onAttachToRoot
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onAttachToSlot
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onActive
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onUpdate
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onDelete
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 isApplicable
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 alterFormValues
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 60
0.00% covered (danger)
0.00%
0 / 1
72
 isEmpty
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 isMultipleItemsSlotSource
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 removeItemSelector
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\display_builder\Plugin\display_builder\Island;
6
7use Drupal\Core\Form\FormStateInterface;
8use Drupal\Core\Render\Element;
9use Drupal\Core\StringTranslation\TranslatableMarkup;
10use Drupal\display_builder\Attribute\Island;
11use Drupal\display_builder\InstanceInterface;
12use Drupal\display_builder\Island\IslandPluginBase;
13use Drupal\display_builder\Island\IslandType;
14use Drupal\display_builder\Island\IslandWithFormInterface;
15use Drupal\display_builder\Island\IslandWithFormTrait;
16use Drupal\display_builder\SourceWithSlotsInterface;
17
18/**
19 * Instance form island plugin implementation.
20 */
21#[Island(
22  id: 'contextual_form',
23  enabled_by_default: TRUE,
24  label: new TranslatableMarkup('Config'),
25  description: new TranslatableMarkup('Configure the active component or block.'),
26  type: IslandType::Contextual,
27  icon: 'sliders',
28)]
29class ContextualFormPanel extends IslandPluginBase implements IslandWithFormInterface {
30
31  use IslandWithFormTrait;
32
33  /**
34   * {@inheritdoc}
35   */
36  public function buildForm(array &$form, FormStateInterface $form_state): void {
37    try {
38      $contexts = $form_state->getBuildInfo()['args'][1] ?? [];
39
40      $this->alterFormValues($form_state);
41      $source = $this->sourceManager->getSource($this->data['node_id'], [], $this->data, $contexts);
42
43      if ($source instanceof SourceWithSlotsInterface) {
44        $form = $source->settingsFormPropsOnly([], $form_state);
45      }
46      else {
47        $form = $source ? $source->settingsForm([], $form_state) : [];
48      }
49
50      if ($this->isMultipleItemsSlotSource($this->data['source'])) {
51        $form = $this->removeItemSelector($form);
52      }
53    }
54    catch (\Exception $e) {
55      $this->logger->error('Error building contextual form for node @node_id: @message', [
56        '@node_id' => $this->data['node_id'] ?? 'unknown',
57        '@message' => $e->getMessage(),
58      ]);
59    }
60  }
61
62  /**
63   * {@inheritdoc}
64   */
65  public function build(InstanceInterface $builder, array $data = [], array $options = []): array {
66    $build = parent::build($builder, $data, $options);
67
68    if (empty($build)) {
69      return $build;
70    }
71
72    if (self::isEmpty($build)) {
73      return [
74        '#type' => 'html_tag',
75        '#tag' => 'p',
76        '#value' => $this->t('No configuration required.'),
77        '#attributes' => [
78          'class' => ['description'],
79        ],
80      ];
81    }
82
83    $build = [
84      'source' => $build,
85    ];
86
87    $build['update'] = [
88      '#type' => 'button',
89      '#value' => 'Update',
90      '#submit_button' => FALSE,
91      '#attributes' => [
92        'type' => 'button',
93        'data-testid' => 'contextual_form_update',
94      ],
95    ];
96
97    $build = $this->htmxEvents->onInstanceFormChange($build, $this->builderId, $this->getPluginId(), $this->data['node_id']);
98
99    return $this->htmxEvents->onInstanceUpdateButtonClick($build, $this->builderId, $this->getPluginId(), $this->data['node_id']);
100  }
101
102  /**
103   * {@inheritdoc}
104   *
105   * No-op: a freshly attached node always has a brand-new node_id, so its
106   * own contextual panel can never already be open in the second drawer for
107   * this or any other reload to target.
108   */
109  public function onAttachToRoot(InstanceInterface $instance, string $node_id): array {
110    return [];
111  }
112
113  /**
114   * {@inheritdoc}
115   *
116   * @see self::onAttachToRoot()
117   */
118  public function onAttachToSlot(InstanceInterface $instance, string $node_id, string $parent_id): array {
119    return [];
120  }
121
122  /**
123   * {@inheritdoc}
124   */
125  public function onActive(InstanceInterface $instance, array $data): array {
126    return $this->reloadWithLocalData($instance, $data);
127  }
128
129  /**
130   * {@inheritdoc}
131   */
132  public function onUpdate(InstanceInterface $instance, string $node_id): array {
133    // Reload the form itself on update.
134    $data = $instance->getNode($node_id);
135
136    return $this->reloadWithLocalData($instance, $data);
137  }
138
139  /**
140   * {@inheritdoc}
141   */
142  public function onDelete(InstanceInterface $instance, ?string $parent_id): array {
143    return $this->reloadWithLocalData($instance, []);
144  }
145
146  /**
147   * {@inheritdoc}
148   */
149  public function isApplicable(): bool {
150    return isset($this->data['source_id']) && isset($this->data['node_id']);
151  }
152
153  /**
154   * Alter the form values.
155   *
156   * @param \Drupal\Core\Form\FormStateInterface $form_state
157   *   The form state.
158   */
159  protected function alterFormValues(FormStateInterface $form_state): void {
160    // When this is an Ajax CALL, we directly inject the data into the source
161    // settings, but not during the rebuilt.
162    $values = $form_state->getValues();
163
164    if (isset($values['_drupal_ajax']) && $values['_drupal_ajax'] && !$form_state->isRebuilding()) {
165      if ($this->data['source_id'] !== 'component') {
166        $this->data['source'] = $values;
167      }
168    }
169
170    // When rebuilding the form, we need to inject the values into the source
171    // settings.
172    if ($form_state->isRebuilding()) {
173      // Allow to get the posted values through ajax, and give them to the
174      // source plugin through its settings (essential).
175      if (isset($values['source'])) {
176        unset($values['source']);
177      }
178
179      if (!empty($values)) {
180        $this->data['source'] = $values;
181      }
182    }
183  }
184
185  /**
186   * Indicates whether the given form array is empty.
187   *
188   * @param array $form
189   *   The form.
190   *
191   * @return bool
192   *   Whether the given element is empty.
193   */
194  private static function isEmpty(array $form) {
195    $keys = Element::children($form);
196
197    // Quick valid if a component. An empty component is a rare occurrence.
198    if (isset($keys['component'])) {
199      return FALSE;
200    }
201
202    return \array_diff(Element::children($form), [
203      'plugin_id',
204      'form_build_id',
205      'form_token',
206      'form_id',
207      // Exclude some core block with no configuration.
208      // @todo remove when we do not need the update button anymore.
209      'help_block',
210      'local_actions_block',
211      'node_syndicate_block',
212      'system_breadcrumb_block',
213      'system_clear_cache_block',
214      'system_messages_block',
215      'system_powered_by_block',
216    ]) === [];
217  }
218
219  /**
220   * Has the slot source multiple items?
221   *
222   * Some slot sources have 'multiple' items, with a select form element first,
223   * then an item specific form changing with Ajax. They have both a plugin_id
224   * key and a dynamic key with the value of the plugin_id.
225   *
226   * @param array $data
227   *   The slot source data containing:
228   *   - plugin_id: The plugin ID.
229   *
230   * @return bool
231   *   Is multiple or not.
232   */
233  private function isMultipleItemsSlotSource(array $data): bool {
234    if (!isset($data['plugin_id']) || !\is_string($data['plugin_id'])) {
235      return FALSE;
236    }
237
238    if (\count($data) === 1) {
239      // If there is only plugin_id, without any settings, it is OK.
240      return TRUE;
241    }
242    // If there are settings, we need at least the one specific to the item.
243    $plugin_id = (string) $data['plugin_id'];
244
245    if (isset($data[$plugin_id]) && \is_array($data[$plugin_id])) {
246      return TRUE;
247    }
248
249    return FALSE;
250  }
251
252  /**
253   * Remove the item selector from a form.
254   *
255   * For multiple items slot sources, we don't want to show the item selector
256   * since it is already selected in the slot configuration.
257   *
258   * @param array $form
259   *   The form array.
260   *
261   * @return array
262   *   The modified form array.
263   */
264  private function removeItemSelector(array $form): array {
265    $form['plugin_id']['#type'] = 'hidden';
266    unset($form['plugin_id']['#options']);
267
268    return $form;
269  }
270
271}