Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
4.49% covered (danger)
4.49%
12 / 267
25.00% covered (danger)
25.00%
3 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
VisitorsDisplayLink
4.49% covered (danger)
4.49%
12 / 267
25.00% covered (danger)
25.00%
3 / 12
3196.10
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 create
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 defineOptions
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 buildOptionsForm
0.00% covered (danger)
0.00%
0 / 80
0.00% covered (danger)
0.00%
0 / 1
156
 removeDisplayLink
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 validate
0.00% covered (danger)
0.00%
0 / 49
0.00% covered (danger)
0.00%
0 / 1
240
 render
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
272
 renderSingleLink
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
20
 renderRadioButtons
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 renderCheckboxes
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 renderUnorderedList
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 ajaxDisplayChange
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace Drupal\visitors\Plugin\views\area;
4
5use Drupal\Core\Config\ImmutableConfig;
6use Drupal\Core\EventSubscriber\AjaxResponseSubscriber;
7use Drupal\Core\EventSubscriber\MainContentViewSubscriber;
8use Drupal\Core\Form\FormBuilderInterface;
9use Drupal\Core\Form\FormStateInterface;
10use Drupal\Core\Url;
11use Drupal\views\Attribute\ViewsArea;
12use Drupal\views\Plugin\views\area\DisplayLink;
13use Symfony\Component\DependencyInjection\ContainerInterface;
14
15/**
16 * Views area display_link handler.
17 *
18 * @ingroup views_area_handlers
19 */
20#[ViewsArea("visitors_display_link")]
21class VisitorsDisplayLink extends DisplayLink {
22
23  /**
24   * The view settings.
25   *
26   * @var \Drupal\Core\Config\ImmutableConfig
27   */
28  protected $viewSettings;
29
30  /**
31   * Constructs a new VisitorsDisplayLink object.
32   *
33   * @param array $configuration
34   *   The plugin configuration.
35   * @param string $plugin_id
36   *   The plugin ID.
37   * @param mixed $plugin_definition
38   *   The plugin definition.
39   * @param \Drupal\Core\Config\ImmutableConfig $view_settings
40   *   The view settings.
41   */
42  public function __construct(array $configuration, $plugin_id, $plugin_definition, ImmutableConfig $view_settings) {
43    parent::__construct($configuration, $plugin_id, $plugin_definition);
44    $this->viewSettings = $view_settings;
45  }
46
47  /**
48   * {@inheritdoc}
49   */
50  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
51    return new self(
52      $configuration,
53      $plugin_id,
54      $plugin_definition,
55      $container->get('config.factory')->get('views.settings')
56    );
57  }
58
59  /**
60   * {@inheritdoc}
61   */
62  protected function defineOptions() {
63    $options = parent::defineOptions();
64    $options['display_links'] = ['default' => []];
65    $options['render_format'] = ['default' => 'link'];
66    return $options;
67  }
68
69  /**
70   * {@inheritdoc}
71   */
72  public function buildOptionsForm(&$form, FormStateInterface $form_state) {
73    parent::buildOptionsForm($form, $form_state);
74
75    $allowed_displays = [];
76    $displays = $this->view->storage->get('display');
77    foreach ($displays as $display_id => $display) {
78      if ($this->isPathBasedDisplay($display_id)) {
79        unset($displays[$display_id]);
80        continue;
81      }
82      $allowed_displays[$display_id] = $display['display_title'];
83    }
84
85    $form['description'] = [
86      [
87        '#markup' => $this->t('To make sure the results are the same when switching to the other display, it is recommended to make sure the display:'),
88      ],
89      [
90        '#theme' => 'item_list',
91        '#items' => [
92          $this->t('Has a path.'),
93          $this->t('Has the same filter criteria.'),
94          $this->t('Has the same sort criteria.'),
95          $this->t('Has the same contextual filters.'),
96        ],
97      ],
98    ];
99
100    if (!$allowed_displays) {
101      $form['empty_message'] = [
102        '#markup' => '<p><em>' . $this->t('There are no path-based displays available.') . '</em></p>',
103      ];
104    }
105    else {
106      // Render format selection.
107      $form['render_format'] = [
108        '#title' => $this->t('Render format'),
109        '#type' => 'select',
110        '#options' => [
111          'link' => $this->t('Single link (if only one display)'),
112          'radio' => $this->t('Radio buttons'),
113          'checkboxes' => $this->t('Checkboxes'),
114          'list' => $this->t('Unordered list of links'),
115        ],
116        '#default_value' => (string) ($this->options['render_format'] ?? 'link'),
117        '#description' => $this->t('Choose how to render the display links.'),
118      ];
119
120      // Display links configuration.
121      $form['display_links'] = [
122        '#type' => 'fieldset',
123        '#title' => $this->t('Display Links'),
124        '#description' => $this->t('Configure multiple display links. Each link can have its own label.'),
125        '#tree' => TRUE,
126      ];
127
128      $display_links = $this->options['display_links'] ?? [];
129      if (empty($display_links)) {
130        // Initialize with one empty link if none exist.
131        $display_links = [['display_id' => '', 'label' => '']];
132      }
133      else {
134        // Always add an empty link at the end for adding new ones.
135        $display_links[] = ['display_id' => '', 'label' => ''];
136      }
137
138      $total_links = count($display_links);
139      foreach ($display_links as $index => $link) {
140        // Ensure we have a valid array structure.
141        if (!is_array($link)) {
142          continue;
143        }
144
145        $is_empty = empty((string) ($link['display_id'] ?? '')) && empty((string) ($link['label'] ?? ''));
146        $is_last = ($index === $total_links - 1);
147
148        $form['display_links'][$index] = [
149          '#type' => 'fieldset',
150          '#title' => $is_empty ? $this->t('Add new link') : $this->t('Link @index', ['@index' => (int) $index + 1]),
151          '#collapsible' => TRUE,
152          '#collapsed' => $is_empty ? FALSE : ($index > 0),
153        ];
154
155        $form['display_links'][$index]['display_id'] = [
156          '#title' => $this->t('Display'),
157          '#type' => 'select',
158          '#options' => ['' => $this->t('- Select -')] + $allowed_displays,
159          '#default_value' => (string) ($link['display_id'] ?? ''),
160          '#required' => !$is_empty,
161        ];
162
163        $form['display_links'][$index]['label'] = [
164          '#title' => $this->t('Label'),
165          '#description' => $this->t('The text of the link.'),
166          '#type' => 'textfield',
167          '#default_value' => (string) ($link['label'] ?? ''),
168          '#required' => !$is_empty,
169        ];
170
171        // Only show remove button for non-empty links that are not the last
172        // one.
173        if (!$is_empty && !$is_last) {
174          $form['display_links'][$index]['remove'] = [
175            '#type' => 'submit',
176            '#value' => $this->t('Remove'),
177            '#name' => 'remove_link_' . $index,
178            '#submit' => [[$this, 'removeDisplayLink']],
179          ];
180        }
181      }
182
183    }
184  }
185
186  /**
187   * AJAX callback to remove a display link.
188   */
189  public function removeDisplayLink(array &$form, FormStateInterface $form_state) {
190    $triggering_element = $form_state->getTriggeringElement();
191    $index = (int) str_replace('remove_link_', '', $triggering_element['#name']);
192
193    $display_links = $form_state->getValue('display_links', []);
194    unset($display_links[$index]);
195    // Re-index array.
196    $display_links = array_values($display_links);
197    $form_state->setValue('display_links', $display_links);
198    $form_state->setRebuild();
199  }
200
201  /**
202   * {@inheritdoc}
203   */
204  public function validate() {
205    $errors = [];
206
207    // Do not add errors for the default display if it is not displayed in the
208    // UI.
209    if ($this->displayHandler->isDefaultDisplay() && !$this->viewSettings->get('ui.show.default_display')) {
210      return $errors;
211    }
212
213    // Validate new display links configuration.
214    $display_links = $this->options['display_links'] ?? [];
215    if (!empty($display_links)) {
216      foreach ($display_links as $index => $link) {
217        // Ensure we have valid array structure and convert values to strings.
218        if (!is_array($link) || empty($link['display_id']) || empty($link['label'])) {
219          // Skip empty links.
220          continue;
221        }
222
223        $linked_display_id = (string) $link['display_id'];
224        $link_label = (string) $link['label'];
225
226        // Check if the linked display hasn't been removed.
227        if (!$this->view->displayHandlers->get($linked_display_id)) {
228          $errors[] = $this->t('%current_display: Link @index in the %area area points to the %linked_display display which no longer exists.', [
229            '%current_display' => $this->displayHandler->display['display_title'],
230            '@index' => $index + 1,
231            '%area' => $this->areaType,
232            '%linked_display' => $linked_display_id,
233          ]);
234          continue;
235        }
236
237        // Check if the linked display is a path-based display.
238        if ($this->isPathBasedDisplay($linked_display_id)) {
239          $errors[] = $this->t('%current_display: Link @index in the %area area points to the %linked_display display which does not have a path.', [
240            '%current_display' => $this->displayHandler->display['display_title'],
241            '@index' => $index + 1,
242            '%area' => $this->areaType,
243            '%linked_display' => $this->view->displayHandlers->get($linked_display_id)->display['display_title'],
244          ]);
245          continue;
246        }
247
248        // Check if options of the linked display are equal to the options of
249        // the current display. We "only" show a warning here, because even
250        // though we recommend keeping the display options equal, we do not want
251        // to enforce this.
252        $unequal_options = [
253          'filters' => $this->t('Filter criteria'),
254          'arguments' => $this->t('Contextual filters'),
255        ];
256        foreach (array_keys($unequal_options) as $option) {
257          if ($this->hasEqualOptions($linked_display_id, $option)) {
258            unset($unequal_options[$option]);
259          }
260        }
261
262        if ($unequal_options) {
263          $warning = $this->t('%current_display: Link @index in the %area area points to the %linked_display display which uses different settings than the %current_display display for: %unequal_options. To make sure users see the exact same result when clicking the link, please check that the settings are the same.', [
264            '%current_display' => $this->displayHandler->display['display_title'],
265            '@index' => $index + 1,
266            '%area' => $this->areaType,
267            '%linked_display' => $this->view->displayHandlers->get($linked_display_id)->display['display_title'],
268            '%unequal_options' => implode(', ', $unequal_options),
269          ]);
270          $this->messenger()->addWarning($warning);
271        }
272      }
273
274      // If we have display links configured, return any errors found.
275      if (!empty($errors)) {
276        return $errors;
277      }
278    }
279
280    // If no display links are configured at all, show an error.
281    if (empty($display_links)) {
282      $errors[] = $this->t('%current_display: The link in the %area area has no configured display.', [
283        '%current_display' => $this->displayHandler->display['display_title'],
284        '%area' => $this->areaType,
285      ]);
286    }
287
288    return $errors;
289  }
290
291  /**
292   * {@inheritdoc}
293   */
294  public function render($empty = FALSE) {
295    if ($empty && empty($this->options['empty'])) {
296      return [];
297    }
298
299    // Get display links configuration.
300    $display_links = $this->options['display_links'] ?? [];
301    $render_format = $this->options['render_format'] ?? 'link';
302
303    // Filter out empty links.
304    $display_links = array_filter($display_links, function ($link) {
305      return is_array($link) && !empty($link['display_id']) && !empty($link['label']);
306    });
307
308    if (empty($display_links)) {
309      return [];
310    }
311
312    // Filter out path-based displays.
313    $valid_links = [];
314    foreach ($display_links as $link) {
315      if (!$this->isPathBasedDisplay($link['display_id'])) {
316        $valid_links[] = $link;
317      }
318    }
319
320    if (empty($valid_links)) {
321      return [];
322    }
323
324    // If only one link and format is 'link', render as single link.
325    if (count($valid_links) === 1 && $render_format === 'link') {
326      return $this->renderSingleLink($valid_links[0]);
327    }
328
329    // Render based on format.
330    switch ($render_format) {
331      case 'radio':
332        return $this->renderRadioButtons($valid_links);
333
334      case 'checkboxes':
335        return $this->renderCheckboxes($valid_links);
336
337      case 'list':
338        return $this->renderUnorderedList($valid_links);
339
340      case 'link':
341      default:
342        return $this->renderUnorderedList($valid_links);
343    }
344  }
345
346  /**
347   * Renders a single display link.
348   *
349   * @param array $link
350   *   The link configuration.
351   *
352   * @return array
353   *   The render array for a single link.
354   */
355  protected function renderSingleLink(array $link): array {
356    $label = (string) $link['label'];
357    $display_id = (string) $link['display_id'];
358
359    // Get query parameters from the exposed input and pager.
360    $query = $this->view->getExposedInput();
361    if ($current_page = $this->view->getCurrentPage()) {
362      $query['page'] = $current_page;
363    }
364
365    // @todo Remove this parsing once these are removed from the request in
366    //   https://www.drupal.org/node/2504709.
367    foreach ([
368      'view_name',
369      'view_display_id',
370      'view_args',
371      'view_path',
372      'view_dom_id',
373      'pager_element',
374      'view_base_path',
375      AjaxResponseSubscriber::AJAX_REQUEST_PARAMETER,
376      FormBuilderInterface::AJAX_FORM_REQUEST,
377      MainContentViewSubscriber::WRAPPER_FORMAT,
378    ] as $key) {
379      unset($query[$key]);
380    }
381
382    $storage_id = $this->view->storage->id();
383
384    // Set default classes.
385    $classes = [
386      'views-display-link',
387      'views-display-link-' . $display_id,
388    ];
389    if ($display_id === $this->view->current_display) {
390      $classes[] = 'is-active';
391    }
392    $classes[] = 'use-ajax';
393
394    $path = 'internal:/admin/visitors/_report/' . $storage_id . '/' . $display_id;
395    $query_class = '.view-id-' . $storage_id . '.view-display-id-' . $this->view->current_display;
396
397    $query_params = ['class' => $query_class];
398
399    return [
400      '#type' => 'link',
401      '#title' => $label,
402      '#url' => Url::fromUri($path, [
403        'query' => $query_params,
404      ]),
405      '#options' => [
406        'attributes' => ['class' => $classes],
407      ],
408    ];
409  }
410
411  /**
412   * Renders display links as radio buttons.
413   *
414   * @param array $links
415   *   Array of link configurations.
416   *
417   * @return array
418   *   The render array for radio buttons.
419   */
420  protected function renderRadioButtons(array $links): array {
421    $options = [];
422    $default_value = NULL;
423
424    foreach ($links as $link) {
425      $display_id = (string) $link['display_id'];
426      $label = (string) $link['label'];
427
428      $options[$display_id] = $label;
429
430      if ($display_id === $this->view->current_display) {
431        $default_value = $display_id;
432      }
433    }
434
435    return [
436      '#type' => 'radios',
437      '#title' => $this->t('Display Options'),
438      '#options' => $options,
439      '#default_value' => $default_value,
440      '#attributes' => [
441        'class' => ['visitors-display-radios'],
442        'data-ajax' => 'true',
443      ],
444      '#ajax' => [
445        'callback' => [$this, 'ajaxDisplayChange'],
446        'wrapper' => 'visitors-display-content',
447      ],
448    ];
449  }
450
451  /**
452   * Renders display links as checkboxes.
453   *
454   * @param array $links
455   *   Array of link configurations.
456   *
457   * @return array
458   *   The render array for checkboxes.
459   */
460  protected function renderCheckboxes(array $links): array {
461    $options = [];
462    $default_value = [];
463
464    foreach ($links as $link) {
465      $display_id = (string) $link['display_id'];
466      $label = (string) $link['label'];
467
468      $options[$display_id] = $label;
469
470      if ($display_id === $this->view->current_display) {
471        $default_value[] = $display_id;
472      }
473    }
474
475    return [
476      '#type' => 'checkboxes',
477      '#title' => $this->t('Display Options'),
478      '#options' => $options,
479      '#default_value' => $default_value,
480      '#attributes' => [
481        'class' => ['visitors-display-checkboxes'],
482        'data-ajax' => 'true',
483      ],
484      '#ajax' => [
485        'callback' => [$this, 'ajaxDisplayChange'],
486        'wrapper' => 'visitors-display-content',
487      ],
488    ];
489  }
490
491  /**
492   * Renders display links as an unordered list.
493   *
494   * @param array $links
495   *   Array of link configurations.
496   *
497   * @return array
498   *   The render array for an unordered list.
499   */
500  protected function renderUnorderedList(array $links): array {
501    $items = [];
502
503    foreach ($links as $link) {
504      $items[] = $this->renderSingleLink($link);
505    }
506
507    return [
508      '#theme' => 'item_list',
509      '#items' => $items,
510      '#attributes' => [
511        'class' => ['visitors-display-links'],
512      ],
513    ];
514  }
515
516  /**
517   * AJAX callback for display change.
518   *
519   * @param array $form
520   *   The form array.
521   * @param \Drupal\Core\Form\FormStateInterface $form_state
522   *   The form state.
523   *
524   * @return array
525   *   The AJAX response.
526   */
527  public function ajaxDisplayChange(array &$form, FormStateInterface $form_state) {
528    // This would need to be implemented based on specific requirements
529    // for handling display changes via AJAX.
530    return [];
531  }
532
533}