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