PathPluginBase.php 18.4 KB
Newer Older
1
2
3
4
<?php

namespace Drupal\views\Plugin\views\display;

5
use Drupal\Component\Utility\UrlHelper;
6
use Drupal\Core\Form\FormStateInterface;
7
use Drupal\Core\Language\LanguageInterface;
8
use Drupal\Core\State\StateInterface;
9
10
use Drupal\Core\Routing\RouteCompiler;
use Drupal\Core\Routing\RouteProviderInterface;
11
use Drupal\Core\Url;
12
use Drupal\views\Views;
13
use Symfony\Component\DependencyInjection\ContainerInterface;
14
15
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
16
17
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;
18
19

/**
20
 * The base display plugin for path/callbacks. This is used for pages and feeds.
21
22
 *
 * @see \Drupal\views\EventSubscriber\RouteSubscriber
23
 */
24
abstract class PathPluginBase extends DisplayPluginBase implements DisplayRouterInterface, DisplayMenuInterface {
25

26
27
28
29
30
31
32
33
34
35
  /**
   * The route provider.
   *
   * @var \Drupal\Core\Routing\RouteProviderInterface
   */
  protected $routeProvider;

  /**
   * The state key value store.
   *
36
   * @var \Drupal\Core\State\StateInterface
37
38
39
40
41
42
43
44
45
46
   */
  protected $state;

  /**
   * Constructs a PathPluginBase object.
   *
   * @param array $configuration
   *   A configuration array containing information about the plugin instance.
   * @param string $plugin_id
   *   The plugin_id for the plugin instance.
47
   * @param mixed $plugin_definition
48
49
50
   *   The plugin implementation definition.
   * @param \Drupal\Core\Routing\RouteProviderInterface $route_provider
   *   The route provider.
51
   * @param \Drupal\Core\State\StateInterface $state
52
53
   *   The state key value store.
   */
54
  public function __construct(array $configuration, $plugin_id, $plugin_definition, RouteProviderInterface $route_provider, StateInterface $state) {
55
56
57
58
59
60
61
62
63
    parent::__construct($configuration, $plugin_id, $plugin_definition);

    $this->routeProvider = $route_provider;
    $this->state = $state;
  }

  /**
   * {@inheritdoc}
   */
64
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
65
66
67
68
69
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('router.route_provider'),
70
      $container->get('state')
71
72
73
    );
  }

74
  /**
75
   * {@inheritdoc}
76
77
78
79
80
   */
  public function hasPath() {
    return TRUE;
  }

81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
  /**
   * {@inheritdoc}
   */
  public function getPath() {
    $bits = explode('/', $this->getOption('path'));
    if ($this->isDefaultTabPath()) {
      array_pop($bits);
    }
    return implode('/', $bits);
  }

  /**
   * Determines if this display's path is a default tab.
   *
   * @return bool
   *   TRUE if the display path is for a default tab, FALSE otherwise.
   */
  protected function isDefaultTabPath() {
    $menu = $this->getOption('menu');
    $tab_options = $this->getOption('tab_options');
101
    return $menu && $menu['type'] == 'default tab' && !empty($tab_options['type']) && $tab_options['type'] != 'none';
102
103
  }

104
105
106
107
108
  /**
   * Overrides \Drupal\views\Plugin\views\display\DisplayPluginBase:defineOptions().
   */
  protected function defineOptions() {
    $options = parent::defineOptions();
109
110
    $options['path'] = ['default' => ''];
    $options['route_name'] = ['default' => ''];
111
112
113
114

    return $options;
  }

115
  /**
116
117
118
119
120
121
122
123
124
   * Generates a route entry for a given view and display.
   *
   * @param string $view_id
   *   The ID of the view.
   * @param string $display_id
   *   The current display ID.
   *
   * @return \Symfony\Component\Routing\Route
   *   The route for the view.
125
   */
126
  protected function getRoute($view_id, $display_id) {
127
    $defaults = [
128
      '_controller' => 'Drupal\views\Routing\ViewPageController::handle',
129
      '_title_callback' => 'Drupal\views\Routing\ViewPageController::getTitle',
130
131
      'view_id' => $view_id,
      'display_id' => $display_id,
132
      '_view_display_show_admin_links' => $this->getOption('show_admin_links'),
133
    ];
134
135

    // @todo How do we apply argument validation?
136
137
    $path = $this->getOption('path');

138
139
140
141
142
    // @todo Figure out validation/argument loading.
    // Replace % with %views_arg for menu autoloading and add to the
    // page arguments so the argument actually comes through.
    $arg_counter = 0;

143
    $argument_ids = array_keys((array) $this->getOption('arguments'));
144
145
    $total_arguments = count($argument_ids);

146
    $argument_map = [];
147

148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
    $bits = [];
    if (is_string($path)) {
      $bits = explode('/', $path);
      // Replace arguments in the views UI (defined via %) with parameters in
      // routes (defined via {}). As a name for the parameter use arg_$key, so
      // it can be pulled in the views controller from the request.
      foreach ($bits as $pos => $bit) {
        if ($bit == '%') {
          // Generate the name of the parameter using the key of the argument
          // handler.
          $arg_id = 'arg_' . $arg_counter++;
          $bits[$pos] = '{' . $arg_id . '}';
          $argument_map[$arg_id] = $arg_id;
        }
        elseif (strpos($bit, '%') === 0) {
          // Use the name defined in the path.
          $parameter_name = substr($bit, 1);
          $arg_id = 'arg_' . $arg_counter++;
          $argument_map[$arg_id] = $parameter_name;
          $bits[$pos] = '{' . $parameter_name . '}';
        }
169
      }
170
171
172
173
    }

    // Add missing arguments not defined in the path, but added as handler.
    while (($total_arguments - $arg_counter) > 0) {
174
      $arg_id = 'arg_' . $arg_counter++;
175
176
177
178
      $bit = '{' . $arg_id . '}';
      // In contrast to the previous loop add the defaults here, as % was not
      // specified, which means the argument is optional.
      $defaults[$arg_id] = NULL;
179
      $argument_map[$arg_id] = $arg_id;
180
181
182
      $bits[] = $bit;
    }

183
184
185
    // If this is to be a default tab, create the route for the parent path.
    if ($this->isDefaultTabPath()) {
      $bit = array_pop($bits);
186
      if (empty($bits)) {
187
188
189
190
        $bits[] = $bit;
      }
    }

191
192
193
194
195
196
197
198
199
200
201
    $route_path = '/' . implode('/', $bits);

    $route = new Route($route_path, $defaults);

    // Add access check parameters to the route.
    $access_plugin = $this->getPlugin('access');
    if (!isset($access_plugin)) {
      // @todo Do we want to support a default plugin in getPlugin itself?
      $access_plugin = Views::pluginManager('access')->createInstance('none');
    }
    $access_plugin->alterRouteDefinition($route);
202

203
    // Set the argument map, in order to support named parameters.
204
    $route->setOption('_view_argument_map', $argument_map);
205
    $route->setOption('_view_display_plugin_id', $this->getPluginId());
206
    $route->setOption('_view_display_plugin_class', static::class);
207
208
209
210
    $route->setOption('_view_display_show_admin_links', $this->getOption('show_admin_links'));

    // Store whether the view will return a response.
    $route->setOption('returns_response', !empty($this->getPluginDefinition()['returns_response']));
211

212
213
214
    // Symfony 4 requires that UTF-8 route patterns have the "utf8" option set
    $route->setOption('utf8', TRUE);

215
216
217
218
219
220
221
222
223
224
225
    return $route;
  }

  /**
   * {@inheritdoc}
   */
  public function collectRoutes(RouteCollection $collection) {
    $view_id = $this->view->storage->id();
    $display_id = $this->display['id'];

    $route = $this->getRoute($view_id, $display_id);
226

227
228
229
230
    if (!($route_name = $this->getOption('route_name'))) {
      $route_name = "view.$view_id.$display_id";
    }
    $collection->add($route_name, $route);
231
    return ["$view_id.$display_id" => $route_name];
232
233
  }

234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
  /**
   * Determines whether the view overrides the given route.
   *
   * @param string $view_path
   *   The path of the view.
   * @param \Symfony\Component\Routing\Route $view_route
   *   The route of the view.
   * @param \Symfony\Component\Routing\Route $route
   *   The route itself.
   *
   * @return bool
   *   TRUE, when the view should override the given route.
   */
  protected function overrideApplies($view_path, Route $view_route, Route $route) {
    return $this->overrideAppliesPathAndMethod($view_path, $view_route, $route)
      && (!$route->hasRequirement('_format') || $route->getRequirement('_format') === 'html');
  }

  /**
253
   * Determines whether an override for the path and method should happen.
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
   *
   * @param string $view_path
   *   The path of the view.
   * @param \Symfony\Component\Routing\Route $view_route
   *   The route of the view.
   * @param \Symfony\Component\Routing\Route $route
   *   The route itself.
   *
   * @return bool
   *   TRUE, when the view should override the given route.
   */
  protected function overrideAppliesPathAndMethod($view_path, Route $view_route, Route $route) {
    // Find all paths which match the path of the current display..
    $route_path = RouteCompiler::getPathWithoutDefaults($route);
    $route_path = RouteCompiler::getPatternOutline($route_path);

    // Ensure that we don't override a route which is already controlled by
    // views.
    return !$route->hasDefault('view_id')
    && ('/' . $view_path == $route_path)
    // Also ensure that we don't override for example REST routes.
    && (!$route->getMethods() || in_array('GET', $route->getMethods()));
  }

278
279
280
281
  /**
   * {@inheritdoc}
   */
  public function alterRoutes(RouteCollection $collection) {
282
    $view_route_names = [];
283
    $view_path = $this->getPath();
284
285
286
287
    $view_id = $this->view->storage->id();
    $display_id = $this->display['id'];
    $view_route = $this->getRoute($view_id, $display_id);

288
    foreach ($collection->all() as $name => $route) {
289
      if ($this->overrideApplies($view_path, $view_route, $route)) {
290
291
        $parameters = $route->compile()->getPathVariables();

292
293
294
295
        // @todo Figure out whether we need to merge some settings (like
        // requirements).

        // Replace the existing route with a new one based on views.
296
        $original_route = $collection->get($name);
297
298
        $collection->remove($name);

299
        $path = $view_route->getPath();
300
        // Replace the path with the original parameter names and add a mapping.
301
        $argument_map = [];
302
303
304
        // We assume that the numeric ids of the parameters match the one from
        // the view argument handlers.
        foreach ($parameters as $position => $parameter_name) {
305
          $path = str_replace('{arg_' . $position . '}', '{' . $parameter_name . '}', $path);
306
          $argument_map['arg_' . $position] = $parameter_name;
307
        }
308
309
        // Copy the original options from the route, so for example we ensure
        // that parameter conversion options is carried over.
310
        $view_route->setOptions($view_route->getOptions() + $original_route->getOptions());
311

312
        if ($original_route->hasDefault('_title_callback')) {
313
          $view_route->setDefault('_title_callback', $original_route->getDefault('_title_callback'));
314
315
        }

316
        // Set the corrected path and the mapping to the route object.
317
318
        $view_route->setOption('_view_argument_map', $argument_map);
        $view_route->setPath($path);
319

320
        $collection->add($name, $view_route);
321
322
323
324
325
        $view_route_names[$view_id . '.' . $display_id] = $name;
      }
    }

    return $view_route_names;
326
327
  }

328
329
330
  /**
   * {@inheritdoc}
   */
331
  public function getMenuLinks() {
332
    $links = [];
333
334
335
336
337
338
339
340
341
342
343

    // Replace % with the link to our standard views argument loader
    // views_arg_load -- which lives in views.module.

    $bits = explode('/', $this->getOption('path'));

    // Replace % with %views_arg for menu autoloading and add to the
    // page arguments so the argument actually comes through.
    foreach ($bits as $pos => $bit) {
      if ($bit == '%') {
        // If a view requires any arguments we cannot create a static menu link.
344
        return [];
345
346
347
348
      }
    }

    $path = implode('/', $bits);
349
350
    $view_id = $this->view->storage->id();
    $display_id = $this->display['id'];
351
    $view_id_display = "{$view_id}.{$display_id}";
352
    $menu_link_id = 'views.' . str_replace('/', '.', $view_id_display);
353
354
355
356

    if ($path) {
      $menu = $this->getOption('menu');
      if (!empty($menu['type']) && $menu['type'] == 'normal') {
357
        $links[$menu_link_id] = [];
358
359
        // Some views might override existing paths, so we have to set the route
        // name based upon the altering.
360
        $links[$menu_link_id] = [
361
          'route_name' => $this->getRouteName(),
362
          // Identify URL embedded arguments and correlate them to a handler.
363
          'load arguments'  => [$this->view->storage->id(), $this->display['id'], '%index'],
364
          'id' => $menu_link_id,
365
        ];
366
        $links[$menu_link_id]['title'] = $menu['title'];
367
        $links[$menu_link_id]['description'] = $menu['description'];
368
        $links[$menu_link_id]['parent'] = $menu['parent'];
369
370
        $links[$menu_link_id]['enabled'] = $menu['enabled'];
        $links[$menu_link_id]['expanded'] = $menu['expanded'];
371
372
373
374
375
376

        if (isset($menu['weight'])) {
          $links[$menu_link_id]['weight'] = intval($menu['weight']);
        }

        // Insert item into the proper menu.
377
        $links[$menu_link_id]['menu_name'] = $menu['menu_name'];
378
        // Keep track of where we came from.
379
        $links[$menu_link_id]['metadata'] = [
380
381
          'view_id' => $view_id,
          'display_id' => $display_id,
382
        ];
383
384
385
386
387
388
      }
    }

    return $links;
  }

389
  /**
390
   * {@inheritdoc}
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
   */
  public function execute() {
    // Prior to this being called, the $view should already be set to this
    // display, and arguments should be set on the view.
    $this->view->build();

    if (!empty($this->view->build_info['fail'])) {
      throw new NotFoundHttpException();
    }

    if (!empty($this->view->build_info['denied'])) {
      throw new AccessDeniedHttpException();
    }
  }

  /**
407
   * {@inheritdoc}
408
409
410
411
   */
  public function optionsSummary(&$categories, &$options) {
    parent::optionsSummary($categories, $options);

412
    $categories['page'] = [
413
      'title' => $this->t('Page settings'),
414
      'column' => 'second',
415
      'build' => [
416
        '#weight' => -10,
417
418
      ],
    ];
419

420
421
    $path = strip_tags($this->getOption('path'));

422
    if (empty($path)) {
423
      $path = $this->t('No path is set');
424
425
426
    }
    else {
      $path = '/' . $path;
427
428
    }

429
    $options['path'] = [
430
      'category' => 'page',
431
      'title' => $this->t('Path'),
432
      'value' => views_ui_truncate($path, 24),
433
    ];
434
435
436
  }

  /**
437
   * {@inheritdoc}
438
   */
439
  public function buildOptionsForm(&$form, FormStateInterface $form_state) {
440
441
    parent::buildOptionsForm($form, $form_state);

442
    switch ($form_state->get('section')) {
443
      case 'path':
444
        $form['#title'] .= $this->t('The menu path or URL of this view');
445
        $form['path'] = [
446
          '#type' => 'textfield',
447
          '#title' => $this->t('Path'),
448
          '#description' => $this->t('This view will be displayed by visiting this path on your site. You may use "%" or named route parameters like "%node" in your URL to represent values that will be used for contextual filters: For example, "node/%node/feed" or "view_path/%". Named route parameters are required when this path matches an existing path. For example, paths such as "taxonomy/term/%taxonomy_term" or "user/%user/custom-view".'),
449
          '#default_value' => $this->getOption('path'),
450
          '#field_prefix' => '<span dir="ltr">' . Url::fromRoute('<none>', [], ['absolute' => TRUE])->toString() . '</span>&lrm;',
451
          '#attributes' => ['dir' => LanguageInterface::DIRECTION_LTR],
452
453
          // Account for the leading backslash.
          '#maxlength' => 254,
454
        ];
455
456
457
458
459
        break;
    }
  }

  /**
460
   * {@inheritdoc}
461
   */
462
  public function validateOptionsForm(&$form, FormStateInterface $form_state) {
463
464
    parent::validateOptionsForm($form, $form_state);

465
    if ($form_state->get('section') == 'path') {
466
      $errors = $this->validatePath($form_state->getValue('path'));
467
      foreach ($errors as $error) {
468
        $form_state->setError($form['path'], $error);
469
470
471
      }

      // Automatically remove '/' and trailing whitespace from path.
472
      $form_state->setValue('path', trim($form_state->getValue('path'), '/ '));
473
474
475
476
    }
  }

  /**
477
   * {@inheritdoc}
478
   */
479
  public function submitOptionsForm(&$form, FormStateInterface $form_state) {
480
481
    parent::submitOptionsForm($form, $form_state);

482
    if ($form_state->get('section') == 'path') {
483
      $this->setOption('path', $form_state->getValue('path'));
484
485
486
    }
  }

487
488
489
490
491
492
493
494
495
496
  /**
   * Validates the path of the display.
   *
   * @param string $path
   *   The path to validate.
   *
   * @return array
   *   A list of error strings.
   */
  protected function validatePath($path) {
497
    $errors = [];
498
499
500
501
    if (strpos($path, '%') === 0) {
      $errors[] = $this->t('"%" may not be used for the first segment of a path.');
    }

502
503
504
505
506
507
508
509
510
    $parsed_url = UrlHelper::parse($path);
    if (empty($parsed_url['path'])) {
      $errors[] = $this->t('Path is empty.');
    }

    if (!empty($parsed_url['query'])) {
      $errors[] = $this->t('No query allowed.');
    }

511
    if (!parse_url('internal:/' . $path)) {
512
513
514
      $errors[] = $this->t('Invalid path. Valid characters are alphanumerics as well as "-", ".", "_" and "~".');
    }

515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
    $path_sections = explode('/', $path);
    // Symfony routing does not allow to use numeric placeholders.
    // @see \Symfony\Component\Routing\RouteCompiler
    $numeric_placeholders = array_filter($path_sections, function ($section) {
      return (preg_match('/^%(.*)/', $section, $matches)
        && is_numeric($matches[1]));
    });
    if (!empty($numeric_placeholders)) {
      $errors[] = $this->t("Numeric placeholders may not be used. Please use plain placeholders (%).");
    }
    return $errors;
  }

  /**
   * {@inheritdoc}
   */
  public function validate() {
    $errors = parent::validate();

    $errors += $this->validatePath($this->getOption('path'));

    return $errors;
  }

539
540
541
542
  /**
   * {@inheritdoc}
   */
  public function getUrlInfo() {
543
544
    return Url::fromRoute($this->getRouteName());
  }
545

546
547
548
549
550
551
552
553
554
555
556
  /**
   * {@inheritdoc}
   */
  public function getRouteName() {
    $view_id = $this->view->storage->id();
    $display_id = $this->display['id'];
    $view_route_key = "$view_id.$display_id";

    // Check for overridden route names.
    $view_route_names = $this->getAlteredRouteNames();

557
    return (isset($view_route_names[$view_route_key]) ? $view_route_names[$view_route_key] : "view.$view_route_key");
558
559
560
561
562
563
  }

  /**
   * {@inheritdoc}
   */
  public function getAlteredRouteNames() {
564
    return $this->state->get('views.view_route_names', []);
565
  }
566

567
568
569
570
571
572
573
574
575
576
577
578
  /**
   * {@inheritdoc}
   */
  public function remove() {
    $menu_links = $this->getMenuLinks();
    /** @var \Drupal\Core\Menu\MenuLinkManagerInterface $menu_link_manager */
    $menu_link_manager = \Drupal::service('plugin.manager.menu.link');
    foreach ($menu_links as $menu_link_id => $menu_link) {
      $menu_link_manager->removeDefinition("views_view:$menu_link_id");
    }
  }

579
}