diff --git a/core/modules/file/config/optional/views.view.files.yml b/core/modules/file/config/optional/views.view.files.yml index 8a9cdf0..5d83195 100644 --- a/core/modules/file/config/optional/views.view.files.yml +++ b/core/modules/file/config/optional/views.view.files.yml @@ -1019,7 +1019,7 @@ display: title_enable: false title: All title_enable: true - title: 'File usage information for %1' + title: 'File usage information for {{ arguments.fid }}' default_argument_type: fixed default_argument_options: argument: '' diff --git a/core/modules/node/config/optional/views.view.archive.yml b/core/modules/node/config/optional/views.view.archive.yml index 56ae50e..c30650b 100644 --- a/core/modules/node/config/optional/views.view.archive.yml +++ b/core/modules/node/config/optional/views.view.archive.yml @@ -87,7 +87,7 @@ display: exception: title_enable: true title_enable: true - title: '%1' + title: '{{ arguments.created_year_month }}' default_argument_type: fixed summary: sort_order: desc @@ -186,7 +186,7 @@ display: exception: title_enable: true title_enable: true - title: '%1' + title: '{{ arguments.created_year_month }}' default_argument_type: fixed summary: format: default_summary diff --git a/core/modules/taxonomy/config/optional/views.view.taxonomy_term.yml b/core/modules/taxonomy/config/optional/views.view.taxonomy_term.yml index e1086d1..7a32583 100644 --- a/core/modules/taxonomy/config/optional/views.view.taxonomy_term.yml +++ b/core/modules/taxonomy/config/optional/views.view.taxonomy_term.yml @@ -105,7 +105,7 @@ display: title_enable: false title: All title_enable: true - title: '%1' + title: '{{ arguments.tid }}' default_argument_type: fixed default_argument_options: argument: '' @@ -231,7 +231,7 @@ display: admin_label: '' empty: true tokenize: true - target: '!1' + target: '{{ raw_arguments.tid }}' view_mode: full bypass_access: false plugin_id: entity diff --git a/core/modules/user/tests/modules/user_test_views/test_views/views.view.test_user_roles_rid.yml b/core/modules/user/tests/modules/user_test_views/test_views/views.view.test_user_roles_rid.yml index 71c0a4d..65f2fe3 100644 --- a/core/modules/user/tests/modules/user_test_views/test_views/views.view.test_user_roles_rid.yml +++ b/core/modules/user/tests/modules/user_test_views/test_views/views.view.test_user_roles_rid.yml @@ -169,7 +169,7 @@ display: title_enable: false title: All title_enable: true - title: '%1' + title: '{{ arguments.roles_target_id }}' default_argument_type: fixed default_argument_options: argument: '' diff --git a/core/modules/user/tests/modules/user_test_views/test_views/views.view.test_user_uid_argument.yml b/core/modules/user/tests/modules/user_test_views/test_views/views.view.test_user_uid_argument.yml index fefcc42..4ff201c 100644 --- a/core/modules/user/tests/modules/user_test_views/test_views/views.view.test_user_uid_argument.yml +++ b/core/modules/user/tests/modules/user_test_views/test_views/views.view.test_user_uid_argument.yml @@ -28,7 +28,7 @@ display: table: users_field_data field: uid title_enable: true - title: '%1' + title: '{{ arguments.uid }}' plugin_id: user_uid entity_type: user entity_field: uid diff --git a/core/modules/views/src/Plugin/views/PluginBase.php b/core/modules/views/src/Plugin/views/PluginBase.php index 49b7f1d..c2d9f0f 100644 --- a/core/modules/views/src/Plugin/views/PluginBase.php +++ b/core/modules/views/src/Plugin/views/PluginBase.php @@ -337,10 +337,6 @@ public function globalTokenReplace($string = '', array $options = array()) { * Replaces Views' tokens in a given string. The resulting string will be * sanitized with Xss::filterAdmin. * - * This used to be a simple strtr() scattered throughout the code. Some Views - * tokens, such as arguments (e.g.: %1 or !1), still use the old format so we - * handle those as well as the new Twig-based tokens (e.g.: {{ field_name }}) - * * @param $text * Unsanitized string with possible tokens. * @param $tokens @@ -357,34 +353,44 @@ protected function viewsTokenReplace($text, $tokens) { return Xss::filterAdmin($text); } - // Separate Twig tokens from other tokens (e.g.: contextual filter tokens in - // the form of %1). $twig_tokens = array(); - $other_tokens = array(); foreach ($tokens as $token => $replacement) { + // Twig wants a token replacement array stripped of curly-brackets. + // Some Views tokens come with curly-braces, others do not. + //@todo: https://www.drupal.org/node/2544392 if (strpos($token, '{{') !== FALSE) { // Twig wants a token replacement array stripped of curly-brackets. - $token = trim(str_replace(array('{', '}'), '', $token)); + $token = trim(str_replace(['{{', '}}'], '', $token)); + } + // Check for arrays in Twig tokens. Internally these are passed as + // dot-delimited strings, but need to be turned into associative arrays + // for parsing. + if (strpos($token, '.') === FALSE) { // We need to validate tokens are valid Twig variables. Twig uses the // same variable naming rules as PHP. // @see http://php.net/manual/en/language.variables.basics.php assert('preg_match(\'/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/\', $token) === 1', 'Tokens need to be valid Twig variables.'); - $twig_tokens[$token] = $replacement; } else { - $other_tokens[$token] = $replacement; + $parts = explode('.', $token); + $top = array_shift($parts); + assert('preg_match(\'/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/\', $top) === 1', 'Tokens need to be valid Twig variables.'); + $token_array = array(array_pop($parts) => $replacement); + foreach(array_reverse($parts) as $key) { + assert('preg_match(\'/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*$/\', $key) === 1', 'Tokens need to be valid Twig variables.'); + $token_array = array($key => $token_array); + } + $twig_tokens[$top] = $token_array; } } - // Non-Twig tokens are a straight string replacement, Twig tokens get run - // through an inline template for rendering and replacement. - $text = strtr($text, $other_tokens); if ($twig_tokens) { // Use the unfiltered text for the Twig template, then filter the output. // Otherwise, Xss::filterAdmin could remove valid Twig syntax before the // template is parsed. + $build = array( '#type' => 'inline_template', '#template' => $text, @@ -396,10 +402,7 @@ function ($children, $elements) { ], ); - return (string) $this->getRenderer()->render($build); - } - else { - return $text; + return (string) $this->getRenderer()->renderPlain($build); } } diff --git a/core/modules/views/src/Plugin/views/area/Entity.php b/core/modules/views/src/Plugin/views/area/Entity.php index df5e25e..3234f88 100644 --- a/core/modules/views/src/Plugin/views/area/Entity.php +++ b/core/modules/views/src/Plugin/views/area/Entity.php @@ -113,7 +113,7 @@ public function buildOptionsForm(&$form, FormStateInterface $form_state) { // display the entity ID to the admin form user. // @todo Use a method to check for tokens in // https://www.drupal.org/node/2396607. - if (strpos($this->options['target'], '{{') === FALSE && strpos($this->options['target'], '!') === FALSE && strpos($this->options['target'], '%') === FALSE && strpos($this->options['target'], '[') === FALSE) { + if (strpos($this->options['target'], '{{') === FALSE) { // @todo If the entity does not exist, this will will show the config // target identifier. Decide if this is the correct behavior in // https://www.drupal.org/node/2415391. @@ -146,7 +146,7 @@ public function submitOptionsForm(&$form, FormStateInterface $form_state) { // @todo Use a method to check for tokens in // https://www.drupal.org/node/2396607. $options = $form_state->getValue('options'); - if (strpos($options['target'], '{{') === FALSE && strpos($options['target'], '!') === FALSE && strpos($options['target'], '%') === FALSE && strpos($options['target'], '[') === FALSE) { + if (strpos($options['target'], '{{') === FALSE) { if ($entity = $this->entityManager->getStorage($this->entityType)->load($options['target'])) { $options['target'] = $entity->getConfigTarget(); } @@ -161,7 +161,7 @@ public function render($empty = FALSE) { if (!$empty || !empty($this->options['empty'])) { // @todo Use a method to check for tokens in // https://www.drupal.org/node/2396607. - if (strpos($this->options['target'], '{{') !== FALSE || strpos($this->options['target'], '!') !== FALSE || strpos($this->options['target'], '%') !== FALSE || strpos($this->options['target'], '[') !== FALSE) { + if (strpos($this->options['target'], '{{') !== FALSE) { $target_id = $this->tokenizeValue($this->options['target']); if ($entity = $this->entityManager->getStorage($this->entityType)->load($target_id)) { $target_entity = $entity; @@ -190,7 +190,7 @@ public function calculateDependencies() { // Ensure that we don't add dependencies for placeholders. // @todo Use a method to check for tokens in // https://www.drupal.org/node/2396607. - if (strpos($this->options['target'], '{{') === FALSE && strpos($this->options['target'], '!') === FALSE && strpos($this->options['target'], '%') === FALSE && strpos($this->options['target'], '[') === FALSE) { + if (strpos($this->options['target'], '{{') === FALSE) { if ($entity = $this->entityManager->loadEntityByConfigTarget($this->entityType, $this->options['target'])) { $dependencies[$this->entityManager->getDefinition($this->entityType)->getConfigDependencyKey()][] = $entity->getConfigDependencyName(); } diff --git a/core/modules/views/src/Plugin/views/area/TokenizeAreaPluginBase.php b/core/modules/views/src/Plugin/views/area/TokenizeAreaPluginBase.php index 82bb51d..18a9bd5 100644 --- a/core/modules/views/src/Plugin/views/area/TokenizeAreaPluginBase.php +++ b/core/modules/views/src/Plugin/views/area/TokenizeAreaPluginBase.php @@ -53,13 +53,12 @@ public function tokenForm(&$form, FormStateInterface $form_state) { // Get a list of the available fields and arguments for token replacement. $options = array(); foreach ($this->view->display_handler->getHandlers('field') as $field => $handler) { - $options[t('Fields')]["[$field]"] = $handler->adminLabel(); + $options[t('Fields')]["{{ $field }}"] = $handler->adminLabel(); } - $count = 0; // This lets us prepare the key as we want it printed. - foreach ($this->view->display_handler->getHandlers('argument') as $handler) { - $options[t('Arguments')]['%' . ++$count] = $this->t('@argument title', array('@argument' => $handler->adminLabel())); - $options[t('Arguments')]['!' . $count] = $this->t('@argument input', array('@argument' => $handler->adminLabel())); + foreach ($this->view->display_handler->getHandlers('argument') as $arg => $handler) { + $options[t('Arguments')]["{{ arguments.$arg }}"] = $this->t('@argument title', array('@argument' => $handler->adminLabel())); + $options[t('Arguments')]["{{ raw_arguments.$arg }}"] = $this->t('@argument input', array('@argument' => $handler->adminLabel())); } if (!empty($options)) { @@ -75,7 +74,7 @@ public function tokenForm(&$form, FormStateInterface $form_state) { ), ); $form['tokens']['help'] = array( - '#markup' => '

' . $this->t('The following tokens are available. If you would like to have the characters \'[\' and \']\' use the HTML entity codes \'%5B\' or \'%5D\' or they will get replaced with empty space.') . '

', + '#markup' => '

' . $this->t('The following tokens are available. You may use Twig syntax in this field.') . '

', ); foreach (array_keys($options) as $type) { if (!empty($options[$type])) { diff --git a/core/modules/views/src/Plugin/views/argument/ArgumentPluginBase.php b/core/modules/views/src/Plugin/views/argument/ArgumentPluginBase.php index a1f1c5e..de1818d 100644 --- a/core/modules/views/src/Plugin/views/argument/ArgumentPluginBase.php +++ b/core/modules/views/src/Plugin/views/argument/ArgumentPluginBase.php @@ -211,7 +211,7 @@ public function buildOptionsForm(&$form, FormStateInterface $form_state) { '#title_display' => 'invisible', '#size' => 20, '#default_value' => $this->options['exception']['title'], - '#description' => $this->t('Override the view and other argument titles. Use "%1" for the first argument, "%2" for the second, etc.'), + '#description' => $this->t('Override the view and other argument titles. Use may use Twig syntax in this field. Use "argument.1" for the first argument, "argument.2" for the second, etc.'), '#states' => array( 'visible' => array( ':input[name="options[exception][title_enable]"]' => array('checked' => TRUE), @@ -249,7 +249,7 @@ public function buildOptionsForm(&$form, FormStateInterface $form_state) { '#title' => $this->t('Provide title'), '#title_display' => 'invisible', '#default_value' => $this->options['title'], - '#description' => $this->t('Override the view and other argument titles. Use "%1" for the first argument, "%2" for the second, etc.'), + '#description' => $this->t('Override the view and other argument titles. You may use Twig syntax in this field.'), '#states' => array( 'visible' => array( ':input[name="options[title_enable]"]' => array('checked' => TRUE), @@ -258,6 +258,23 @@ public function buildOptionsForm(&$form, FormStateInterface $form_state) { '#fieldset' => 'argument_present', ); + $output = $this->getTokenHelp(); + $form['token_help'] = [ + '#type' => 'details', + '#title' => $this->t('Replacement patterns'), + '#value' => $output, + '#states' => [ + 'visible' => [ + [ + ':input[name="options[title_enable]"]' => ['checked' => TRUE], + ], + [ + ':input[name="options[exception][title_enable]"]' => ['checked' => TRUE], + ], + ], + ], + ]; + $form['specify_validation'] = array( '#type' => 'checkbox', '#title' => $this->t('Specify validation criteria'), @@ -348,6 +365,46 @@ public function buildOptionsForm(&$form, FormStateInterface $form_state) { ); } + /** + * Provide token help information for the argument. + * + * @return array + * A render array. + */ + protected function getTokenHelp() { + $output = []; + + foreach ($this->view->display_handler->getHandlers('argument') as $arg => $handler) { + /** @var \Drupal\views\Plugin\views\argument\ArgumentPluginBase $handler */ + $options[t('Arguments')]["{{ arguments.$arg }}"] = $this->t('@argument title', array('@argument' => $handler->adminLabel())); + $options[t('Arguments')]["{{ raw_arguments.$arg }}"] = $this->t('@argument input', array('@argument' => $handler->adminLabel())); + } + + // We have some options, so make a list. + if (!empty($options)) { + $output[] = [ + '#markup' => '

' . $this->t("The following replacement tokens are available for this argument.") . '

', + ]; + foreach (array_keys($options) as $type) { + if (!empty($options[$type])) { + $items = array(); + foreach ($options[$type] as $key => $value) { + $items[] = $key . ' == ' . $value; + } + $item_list = array( + '#theme' => 'item_list', + '#items' => $items, + '#list_type' => $type, + ); + $output[] = $item_list; + } + } + } + + return $output; + } + + public function validateOptionsForm(&$form, FormStateInterface $form_state) { $option_values = &$form_state->getValue('options'); if (empty($option_values)) { diff --git a/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php b/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php index 09fa129..96fbe9d 100644 --- a/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php +++ b/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php @@ -1725,17 +1725,16 @@ public function buildOptionsForm(&$form, FormStateInterface $form_state) { ); $options = array(); - $count = 0; // This lets us prepare the key as we want it printed. - foreach ($this->view->display_handler->getHandlers('argument') as $handler) { - $options[t('Arguments')]['%' . ++$count] = $this->t('@argument title', array('@argument' => $handler->adminLabel())); - $options[t('Arguments')]['!' . $count] = $this->t('@argument input', array('@argument' => $handler->adminLabel())); + foreach ($this->view->display_handler->getHandlers('argument') as $arg => $handler) { + $options[t('Arguments')]["{{ arguments.$arg }}"] = $this->t('@argument title', array('@argument' => $handler->adminLabel())); + $options[t('Arguments')]["{{ raw_arguments.$arg }}"] = $this->t('@argument input', array('@argument' => $handler->adminLabel())); } // Default text. // We have some options, so make a list. $output = ''; if (!empty($options)) { - $output = $this->t('

The following tokens are available for this link.

'); + $output = $this->t('

The following tokens are available for this link. You may use Twig syntax in this field.

'); foreach (array_keys($options) as $type) { if (!empty($options[$type])) { $items = array(); diff --git a/core/modules/views/src/Plugin/views/field/FieldPluginBase.php b/core/modules/views/src/Plugin/views/field/FieldPluginBase.php index 152d4e9..0a86fc9 100644 --- a/core/modules/views/src/Plugin/views/field/FieldPluginBase.php +++ b/core/modules/views/src/Plugin/views/field/FieldPluginBase.php @@ -333,7 +333,7 @@ public function elementClasses($row_index = NULL) { * {@inheritdoc} */ public function tokenizeValue($value, $row_index = NULL) { - if (strpos($value, '{{') !== FALSE || strpos($value, '!') !== FALSE || strpos($value, '%') !== FALSE) { + if (strpos($value, '{{') !== FALSE) { $fake_item = array( 'alter_text' => TRUE, 'text' => $value, @@ -868,10 +868,9 @@ public function buildOptionsForm(&$form, FormStateInterface $form_state) { // Add the field to the list of options. $options[t('Fields')]["{{ {$this->options['id']} }}"] = substr(strrchr($this->adminLabel(), ":"), 2 ); - $count = 0; // This lets us prepare the key as we want it printed. foreach ($this->view->display_handler->getHandlers('argument') as $arg => $handler) { - $options[t('Arguments')]['%' . ++$count] = $this->t('@argument title', array('@argument' => $handler->adminLabel())); - $options[t('Arguments')]['!' . $count] = $this->t('@argument input', array('@argument' => $handler->adminLabel())); + $options[t('Arguments')]["{{ arguments.$arg }}"] = $this->t('@argument title', array('@argument' => $handler->adminLabel())); + $options[t('Arguments')]["{{ raw_arguments.$arg }}"] = $this->t('@argument input', array('@argument' => $handler->adminLabel())); } $this->documentSelfTokens($options[t('Fields')]); @@ -1557,7 +1556,7 @@ public function getRenderTokens($item) { } $count = 0; foreach ($this->displayHandler->getHandlers('argument') as $arg => $handler) { - $token = '%' . ++$count; + $token = "{{ arguments.$arg }}"; if (!isset($tokens[$token])) { $tokens[$token] = ''; } @@ -1565,7 +1564,8 @@ public function getRenderTokens($item) { // Use strip tags as there should never be HTML in the path. // However, we need to preserve special characters like " that // were removed by SafeMarkup::checkPlain(). - $tokens['!' . $count] = isset($this->view->args[$count - 1]) ? strip_tags(Html::decodeEntities($this->view->args[$count - 1])) : ''; + $tokens["{{ raw_arguments.$arg }}"] = isset($this->view->args[$count]) ? strip_tags(Html::decodeEntities($this->view->args[$count])) : ''; + $count++; } // Get flattened set of tokens for any array depth in query parameters. @@ -1661,8 +1661,8 @@ protected function getTokenValuesRecursive(array $array, array $parent_keys = ar } else { // Create a token key based on array element structure. - $token_string = !empty($parent_keys) ? implode('_', $parent_keys) . '_' . $param : $param; - $tokens['%' . $token_string] = strip_tags(Html::decodeEntities($val)); + $token_string = !empty($parent_keys) ? implode('.', $parent_keys) . '.' . $param : $param; + $tokens['{{ arguments.' . $token_string . ' }}'] = strip_tags(Html::decodeEntities($val)); } } diff --git a/core/modules/views/src/Plugin/views/style/StylePluginBase.php b/core/modules/views/src/Plugin/views/style/StylePluginBase.php index ccfd1ba..de927cc 100644 --- a/core/modules/views/src/Plugin/views/style/StylePluginBase.php +++ b/core/modules/views/src/Plugin/views/style/StylePluginBase.php @@ -194,7 +194,7 @@ function usesFields() { public function usesTokens() { if ($this->usesRowClass()) { $class = $this->options['row_class']; - if (strpos($class, '{{') !== FALSE || strpos($class, '!') !== FALSE || strpos($class, '%') !== FALSE) { + if (strpos($class, '{{') !== FALSE) { return TRUE; } } @@ -231,7 +231,7 @@ public function getRowClass($row_index) { * Take a value and apply token replacement logic to it. */ public function tokenizeValue($value, $row_index) { - if (strpos($value, '{{') !== FALSE || strpos($value, '!') !== FALSE || strpos($value, '%') !== FALSE) { + if (strpos($value, '{{') !== FALSE) { // Row tokens might be empty, for example for node row style. $tokens = isset($this->rowTokens[$row_index]) ? $this->rowTokens[$row_index] : array(); if (!empty($this->view->build_info['substitutions'])) { diff --git a/core/modules/views/src/Tests/Handler/FieldKernelTest.php b/core/modules/views/src/Tests/Handler/FieldKernelTest.php index 70940d0..aabb813 100644 --- a/core/modules/views/src/Tests/Handler/FieldKernelTest.php +++ b/core/modules/views/src/Tests/Handler/FieldKernelTest.php @@ -27,7 +27,7 @@ class FieldKernelTest extends ViewKernelTestBase { * * @var array */ - public static $testViews = array('test_view', 'test_field_tokens', 'test_field_output'); + public static $testViews = array('test_view', 'test_field_tokens', 'test_field_argument_tokens', 'test_field_output'); /** * Map column names. @@ -174,6 +174,44 @@ public function testRewrite() { } /** + * Tests the arguments tokens on field level. + */ + public function testArgumentTokens() { + /** @var \Drupal\Core\Render\RendererInterface $renderer */ + $renderer = \Drupal::service('renderer'); + + $view = Views::getView('test_field_argument_tokens'); + $this->executeView($view, ['{{ { "#pre_render": ["views_test_data_test_pre_render_function"]} }}']); + + $name_field_0 = $view->field['name']; + + $name_field_0->options['alter']['alter_text'] = TRUE; + $name_field_0->options['alter']['text'] = '%1 !1'; + + $row = $view->result[0]; + $output = $renderer->executeInRenderContext(new RenderContext(), function () use ($name_field_0, $row) { + return $name_field_0->advancedRender($row); + }); + + $this->assertFalse(strpos((string) $output, 'views_test_data_test_pre_render_function executed') !== FALSE); + $this->assertEqual('%1 !1', (string) $output, "Ensure that old style placeholders aren't replaced"); + + // This time use new style tokens but ensure that we still don't allow + // arbitrary code execution. + + $name_field_0->options['alter']['alter_text'] = TRUE; + $name_field_0->options['alter']['text'] = '{{ arguments.name }} {{ raw_arguments.name }}'; + + $row = $view->result[0]; + $output = $renderer->executeInRenderContext(new RenderContext(), function () use ($name_field_0, $row) { + return $name_field_0->advancedRender($row); + }); + + $this->assertFalse(strpos((string) $output, 'views_test_data_test_pre_render_function executed') !== FALSE); + $this->assertEqual(' ', (string) $output, "Ensure that old style placeholders aren't replaced"); + } + + /** * Tests the field tokens, row level and field level. */ public function testFieldTokens() { diff --git a/core/modules/views/src/Tests/Update/ArgumentPlaceholderUpdatePathTest.php b/core/modules/views/src/Tests/Update/ArgumentPlaceholderUpdatePathTest.php new file mode 100644 index 0000000..d123b5d --- /dev/null +++ b/core/modules/views/src/Tests/Update/ArgumentPlaceholderUpdatePathTest.php @@ -0,0 +1,54 @@ +databaseDumpFiles = [ + __DIR__ . '/../../../../system/tests/fixtures/update/drupal-8.bare.standard.php.gz', + __DIR__ . '/../../../tests/fixtures/update/argument-placeholder.php' + ]; + } + + /** + * Ensures that %1 and !1 are converted to twig tokens in existing views. + */ + public function testArgumentPlaceholderUpdate() { + $this->runUpdates(); + $view = View::load('test_token_view'); + + $data = $view->toArray(); + $this->assertEqual('{{ arguments.nid }}-test-class-{{ raw_arguments.nid }}', $data['display']['default']['display_options']['style']['options']['col_class_custom']); + $this->assertEqual('{{ arguments.nid }}-test-class-{{ raw_arguments.nid }}', $data['display']['default']['display_options']['style']['options']['row_class_custom']); + $this->assertEqual('{{ arguments.nid }}-custom-text-{{ raw_arguments.nid }}', $data['display']['default']['display_options']['fields']['title']['alter']['text']); + $this->assertEqual('test_token_view {{ arguments.nid }} {{ raw_arguments.nid }}', $data['display']['default']['display_options']['title']); + $this->assertEqual('{{ arguments.nid }}-custom-{{ raw_arguments.nid }}', $data['display']['default']['display_options']['header']['area_text_custom']['content']); + $this->assertEqual('{{ arguments.nid }}-text-{{ raw_arguments.nid }}', $data['display']['default']['display_options']['footer']['area']['content']['value']); + $this->assertEqual("Displaying @start - @end of @total\n\n{{ arguments.nid }}-result-{{ raw_arguments.nid }}", $data['display']['default']['display_options']['empty']['result']['content']); + $this->assertEqual('{{ arguments.nid }}-title-{{ raw_arguments.nid }}', $data['display']['default']['display_options']['empty']['title']['title']); + $this->assertEqual('{{ arguments.nid }} title {{ raw_arguments.nid }}', $data['display']['default']['display_options']['arguments']['nid']['title']); + $this->assertEqual('{{ arguments.nid }} exception-title {{ raw_arguments.nid }}', $data['display']['default']['display_options']['arguments']['nid']['exception']['title']); + $this->assertEqual('{{ arguments.nid }}-more-text-{{ raw_arguments.nid }}', $data['display']['default']['display_options']['use_more_text']); + $this->assertEqual('{{ arguments.nid }}-custom-url-{{ raw_arguments.nid }}', $data['display']['default']['display_options']['link_url']); + } + +} diff --git a/core/modules/views/src/ViewExecutable.php b/core/modules/views/src/ViewExecutable.php index 2a8145c..3638c9c 100644 --- a/core/modules/views/src/ViewExecutable.php +++ b/core/modules/views/src/ViewExecutable.php @@ -1028,8 +1028,8 @@ protected function _buildArguments() { } // Add this argument's substitution - $substitutions['%' . ($position + 1)] = $arg_title; - $substitutions['!' . ($position + 1)] = strip_tags(Html::decodeEntities($arg)); + $substitutions["{{ arguments.$id }}"] = $arg_title; + $substitutions["{{ raw_arguments.$id }}"] = strip_tags(Html::decodeEntities($arg)); // Test to see if we should use this argument's title if (!empty($argument->options['title_enable']) && !empty($argument->options['title'])) { diff --git a/core/modules/views/tests/fixtures/update/argument-placeholder.php b/core/modules/views/tests/fixtures/update/argument-placeholder.php new file mode 100644 index 0000000..2a277be --- /dev/null +++ b/core/modules/views/tests/fixtures/update/argument-placeholder.php @@ -0,0 +1,19 @@ +insert('config') + ->fields(array( + 'collection', + 'name', + 'data', + )) + ->values(array( + 'collection' => '', + 'name' => 'views.view.test_token_view', + 'data' => serialize(\Drupal\Component\Serialization\Yaml::decode(file_get_contents('core/modules/views/tests/modules/views_test_config/test_views/views.view.test_token_view.yml'))), + )) + ->fields([ + 'collection' => '', + ]) + ->execute(); diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_entity_area.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_entity_area.yml index de5b615..336b043 100644 --- a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_entity_area.yml +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_entity_area.yml @@ -31,7 +31,7 @@ display: field: entity_entity_test id: entity_entity_test table: views - target: '!1' + target: '{{ raw_arguments.id }}' view_mode: full plugin_id: entity entity_block: diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_field_argument_tokens.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_field_argument_tokens.yml new file mode 100644 index 0000000..800beaa --- /dev/null +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_field_argument_tokens.yml @@ -0,0 +1,59 @@ +langcode: en +status: true +dependencies: { } +id: test_field_argument_tokens +label: null +module: views +description: '' +tag: '' +base_table: views_test_data +base_field: nid +core: '8' +display: + default: + display_options: + access: + type: none + cache: + type: tag + exposed_form: + type: basic + pager: + type: full + query: + type: views_query + fields: + name: + id: name + table: views_test_data + field: name + plugin_id: string + name_1: + id: name_1 + table: views_test_data + field: name + plugin_id: string + name_2: + id: name_2 + table: views_test_data + field: name + plugin_id: string + job: + id: job + table: views_test_data + field: job + plugin_id: string + arguments: + null: + id: null + table: views + field: null + plugin_id: ull + style: + type: default + row: + type: fields + display_plugin: default + display_title: Defaults + id: default + position: 0 diff --git a/core/modules/user/tests/modules/user_test_views/test_views/views.view.test_user_roles_rid.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_token_view.yml similarity index 54% copy from core/modules/user/tests/modules/user_test_views/test_views/views.view.test_user_roles_rid.yml copy to core/modules/views/tests/modules/views_test_config/test_views/views.view.test_token_view.yml index 71c0a4d..aa59852 100644 --- a/core/modules/user/tests/modules/user_test_views/test_views/views.view.test_user_roles_rid.yml +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_token_view.yml @@ -2,14 +2,15 @@ langcode: en status: true dependencies: module: + - node - user -id: test_user_roles_rid -label: test_user_roles_rid +id: test_token_view +label: test_token_view module: views description: '' tag: '' -base_table: users_field_data -base_field: uid +base_table: node_field_data +base_field: nid core: 8.x display: default: @@ -19,8 +20,9 @@ display: position: 0 display_options: access: - type: none - options: { } + type: perm + options: + perm: 'access content' cache: type: tag options: { } @@ -64,56 +66,76 @@ display: last: 'last ยป' quantity: 9 style: - type: default + type: grid options: grouping: { } - row_class: '' - default_row_class: true - uses_fields: false + columns: 4 + automatic_width: true + alignment: horizontal + col_class_default: true + col_class_custom: '%1-test-class-!1' + row_class_default: true + row_class_custom: '%1-test-class-!1' row: type: fields options: + default_field_elements: true inline: { } separator: '' hide_empty: false - default_field_elements: true fields: - name: - id: name - table: users_field_data - field: name - entity_type: user - entity_field: name + title: + id: title + table: node_field_data + field: title + relationship: none + group_type: group + admin_label: '' label: '' + exclude: false alter: - alter_text: false + alter_text: true + text: '%1-custom-text-!1' make_link: false + path: '' absolute: false - trim: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 word_boundary: false ellipsis: false + more_link: false + more_link_text: '' + more_link_path: '' strip_tags: false + trim: false + preserve_tags: '' html: false - hide_empty: false - empty_zero: false - plugin_id: field - relationship: none - group_type: group - admin_label: '' - exclude: false element_type: '' element_class: '' element_label_type: '' element_label_class: '' - element_label_colon: true + element_label_colon: false element_wrapper_type: '' element_wrapper_class: '' element_default_classes: true empty: '' + hide_empty: false + empty_zero: false hide_alter_empty: true click_sort_column: value - type: user_name - settings: { } + type: string + settings: + link_to_entity: true group_column: value group_columns: { } group_rows: true @@ -124,52 +146,101 @@ display: multi_type: separator separator: ', ' field_api_classes: false + entity_type: node + entity_field: title + plugin_id: field filters: status: value: true - table: users_field_data + table: node_field_data field: status plugin_id: boolean - entity_type: user + entity_type: node entity_field: status id: status expose: operator: '' group: 1 sorts: - uid: - id: uid - table: users - field: uid + created: + id: created + table: node_field_data + field: created + order: DESC + entity_type: node + entity_field: created + plugin_id: date relationship: none group_type: group admin_label: '' - order: ASC exposed: false expose: label: '' - entity_type: user - entity_field: uid - plugin_id: standard - header: { } - footer: { } - empty: { } + granularity: second + title: 'test_token_view %1 !1' + header: + area_text_custom: + id: area_text_custom + table: views + field: area_text_custom + relationship: none + group_type: group + admin_label: '' + empty: false + tokenize: false + content: '%1-custom-!1' + plugin_id: text_custom + footer: + area: + id: area + table: views + field: area + relationship: none + group_type: group + admin_label: '' + empty: false + tokenize: false + content: + value: '%1-text-!1' + format: basic_html + plugin_id: text + empty: + result: + id: result + table: views + field: result + relationship: none + group_type: group + admin_label: '' + empty: true + content: "Displaying @start - @end of @total\n\n%1-result-!1" + plugin_id: result + title: + id: title + table: views + field: title + relationship: none + group_type: group + admin_label: '' + empty: true + title: '%1-title-!1' + plugin_id: title relationships: { } arguments: - roles_target_id: - id: roles_target_id - table: user__roles - field: roles_target_id + nid: + id: nid + table: node_field_data + field: nid relationship: none group_type: group admin_label: '' - default_action: empty + default_action: ignore exception: value: all - title_enable: false - title: All + title_enable: true + title: '%1 exception-title !1' title_enable: true - title: '%1' + title: '%1 title !1' default_argument_type: fixed default_argument_options: argument: '' @@ -189,10 +260,32 @@ display: fail: 'not found' validate_options: { } break_phrase: false - add_table: false - require_value: false - reduce_duplicates: false - plugin_id: user__roles_rid + not: false + entity_type: node + entity_field: nid + plugin_id: numeric + display_extenders: { } + css_class: '' + use_more: true + use_more_always: true + use_more_text: '%1-more-text-!1' + link_url: '%1-custom-url-!1' + link_display: custom_url + cache_metadata: + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url + - url.query_args + - 'user.node_grants:view' + - user.permissions + cacheable: false + block_1: + display_plugin: block + id: block_1 + display_title: Block + position: 2 + display_options: display_extenders: { } cache_metadata: contexts: @@ -200,6 +293,7 @@ display: - 'languages:language_interface' - url - url.query_args + - 'user.node_grants:view' - user.permissions cacheable: false page_1: @@ -209,12 +303,13 @@ display: position: 1 display_options: display_extenders: { } - path: user_roles_rid_test + path: test-token-view cache_metadata: contexts: - 'languages:language_content' - 'languages:language_interface' - url - url.query_args + - 'user.node_grants:view' - user.permissions cacheable: false diff --git a/core/modules/views/tests/modules/views_test_data/src/Plugin/views/field/FieldTest.php b/core/modules/views/tests/modules/views_test_data/src/Plugin/views/field/FieldTest.php index 0f8feef..5da095a 100644 --- a/core/modules/views/tests/modules/views_test_data/src/Plugin/views/field/FieldTest.php +++ b/core/modules/views/tests/modules/views_test_data/src/Plugin/views/field/FieldTest.php @@ -46,7 +46,7 @@ public function getTestValue() { * Overrides Drupal\views\Plugin\views\field\FieldPluginBase::addSelfTokens(). */ protected function addSelfTokens(&$tokens, $item) { - $tokens['[test__token]'] = $this->getTestValue(); + $tokens['{{ test_token }}'] = $this->getTestValue(); } /** diff --git a/core/modules/views/tests/modules/views_test_data/views_test_data.module b/core/modules/views/tests/modules/views_test_data/views_test_data.module index 1af309c..ecf65b1 100644 --- a/core/modules/views/tests/modules/views_test_data/views_test_data.module +++ b/core/modules/views/tests/modules/views_test_data/views_test_data.module @@ -110,3 +110,17 @@ function template_preprocess_views_view_mapping_test(&$variables) { } } } + +/** + * Test pre_render function. + * + * @param array $element + * A render array + * + * @return array + * The changed render array. + */ +function views_test_data_test_pre_render_function($element) { + $element['#markup'] = 'views_test_data_test_pre_render_function executed'; + return $element; +} diff --git a/core/modules/views/tests/src/Unit/Plugin/area/EntityTest.php b/core/modules/views/tests/src/Unit/Plugin/area/EntityTest.php index bb89281..0c3ff91 100644 --- a/core/modules/views/tests/src/Unit/Plugin/area/EntityTest.php +++ b/core/modules/views/tests/src/Unit/Plugin/area/EntityTest.php @@ -130,10 +130,10 @@ protected function setupEntityManager() { */ public function providerTestTokens() { return [ - ['!1', 5], - ['%2', 6], + ['{{ raw_arguments.test1 }}', 5], + ['{{ arguments.test2 }}', 6], ['{{ test_render_token }}', 7], - ['[test:global_token]', 8], + ['{{ test:global_token }}', 8], ]; } diff --git a/core/modules/views/tests/src/Unit/Plugin/field/FieldPluginBaseTest.php b/core/modules/views/tests/src/Unit/Plugin/field/FieldPluginBaseTest.php index 7b87f22..65471b7 100644 --- a/core/modules/views/tests/src/Unit/Plugin/field/FieldPluginBaseTest.php +++ b/core/modules/views/tests/src/Unit/Plugin/field/FieldPluginBaseTest.php @@ -449,8 +449,8 @@ public function testRenderAsLinkWithPathAndTokens($path, $tokens, $link_html) { ]; $this->renderer->expects($this->once()) - ->method('render') - ->with($build, FALSE) + ->method('renderPlain') + ->with($build) ->willReturn('base:test-path/123'); $result = $field->advancedRender($row); @@ -497,6 +497,67 @@ protected function setupTestField(array $options = []) { return $field; } + /** + * @covers ::getRenderTokens + */ + public function testGetRenderTokensWithoutFieldsAndArguments() { + $field = $this->setupTestField(); + + $this->display->expects($this->any()) + ->method('getHandlers') + ->willReturnMap([ + ['argument', []], + ['field', []], + ]); + + $this->assertEquals([], $field->getRenderTokens([])); + } + + /** + * @covers ::getRenderTokens + */ + public function testGetRenderTokensWithoutArguments() { + $field = $this->setupTestField(['id' => 'id']); + + $field->last_render = 'last rendered output'; + $this->display->expects($this->any()) + ->method('getHandlers') + ->willReturnMap([ + ['argument', []], + ['field', ['id' => $field]], + ]); + + $this->assertEquals(['{{ id }}' => 'last rendered output'], $field->getRenderTokens([])); + } + + /** + * @covers ::getRenderTokens + */ + public function testGetRenderTokensWithArguments() { + $field = $this->setupTestField(['id' => 'id']); + $field->view->args = ['argument value']; + $field->view->build_info['substitutions']['{{ arguments.name }}'] = 'argument value'; + + $argument = $this->getMockBuilder('\Drupal\views\Plugin\views\argument\ArgumentPluginBase') + ->disableOriginalConstructor() + ->getMock(); + + $field->last_render = 'last rendered output'; + $this->display->expects($this->any()) + ->method('getHandlers') + ->willReturnMap([ + ['argument', ['name' => $argument]], + ['field', ['id' => $field]], + ]); + + $expected = [ + '{{ id }}' => 'last rendered output', + '{{ arguments.name }}' => 'argument value', + '{{ raw_arguments.name }}' => 'argument value', + ]; + $this->assertEquals($expected, $field->getRenderTokens([])); + } + } class FieldPluginBaseTestField extends FieldPluginBase { diff --git a/core/modules/views/views.install b/core/modules/views/views.install index 5615747..dce493d 100644 --- a/core/modules/views/views.install +++ b/core/modules/views/views.install @@ -117,5 +117,178 @@ function views_update_8001(&$sandbox) { } /** + * Updates %1 and !1 tokens to argument tokens. + */ +function views_update_8002() { + $config_factory = \Drupal::configFactory(); + foreach ($config_factory->listAll('views.view.') as $view_config_name) { + $view = $config_factory->getEditable($view_config_name); + + $displays = $view->get('display'); + $argument_map_per_display = _views_update_argument_map($displays); + + $changed = FALSE; + + // Update all the field settings, which support tokens. + foreach ($displays as $display_name => &$display) { + if (!empty($display['display_options']['fields'])) { + $token_values = [ + 'path', + 'alt', + 'link_class', + 'rel', + 'target', + 'query', + 'fragment', + 'prefix', + 'suffix', + 'more_link_text', + 'more_link_path', + 'link_attributes', + 'text', + ]; + + foreach ($display['display_options']['fields'] as $field_name => &$field) { + foreach ($token_values as $token_name) { + if (!empty($field['alter'][$token_name])) { + if (is_array($field['alter'][$token_name])) { + foreach (array_keys($field['alter'][$token_name]) as $key) { + $field['alter'][$token_name][$key] = _views_update_8002_token_update($field['alter'][$token_name][$key], $argument_map_per_display[$display_name]); + } + } + else { + $field['alter'][$token_name] = _views_update_8002_token_update($field['alter'][$token_name], $argument_map_per_display[$display_name]); + } + $changed = TRUE; + } + } + } + } + } + + // Update the area handlers with tokens. + foreach ($displays as $display_name => &$display) { + $area_types = ['header', 'footer', 'empty']; + foreach ($area_types as $area_type) { + if (!empty($display['display_options'][$area_type])) { + foreach ($display['display_options'][$area_type] as &$area) { + switch ($area['plugin_id']) { + case 'title': + $area['title'] = _views_update_8002_token_update($area['title'], $argument_map_per_display[$display_name]); + break; + case 'result': + $area['content'] = _views_update_8002_token_update($area['content'], $argument_map_per_display[$display_name]); + break; + case 'text': + $area['content']['value'] = _views_update_8002_token_update($area['content']['value'], $argument_map_per_display[$display_name]); + break; + case 'text_custom': + $area['content'] = _views_update_8002_token_update($area['content'], $argument_map_per_display[$display_name]); + break; + } + } + $changed = TRUE; + } + } + } + + // Update the argument title settings. + foreach ($displays as $display_name => &$display) { + if (!empty($display['display_options']['arguments'])) { + foreach ($display['display_options']['arguments'] as &$argument) { + if (isset($argument['exception']['title'])) { + $argument['exception']['title'] = _views_update_8002_token_update($argument['exception']['title'], $argument_map_per_display[$display_name]); + } + if (isset($argument['title'])) { + $argument['title'] = _views_update_8002_token_update($argument['title'], $argument_map_per_display[$display_name]); + } + } + } + } + + // Update the display title settings. + // Update the more link text and more link URL. + foreach ($displays as $display_name => &$display) { + if (!empty($display['display_options']['title'])) { + $display['display_options']['title'] = _views_update_8002_token_update($display['display_options']['title'], $argument_map_per_display[$display_name]); + } + if (!empty($display['display_options']['use_more_text'])) { + $display['display_options']['use_more_text'] = _views_update_8002_token_update($display['display_options']['use_more_text'], $argument_map_per_display[$display_name]); + } + if (!empty($display['display_options']['link_url'])) { + $display['display_options']['link_url'] = _views_update_8002_token_update($display['display_options']['link_url'], $argument_map_per_display[$display_name]); + } + } + + // Update custom classes for row class + grid classes. + // Update RSS description field. + foreach ($displays as $display_name => &$display) { + if (!empty($display['display_options']['style'])) { + if (!empty($display['display_options']['style']['options']['row_class_custom'])) { + $display['display_options']['style']['options']['row_class_custom'] = _views_update_8002_token_update($display['display_options']['style']['options']['row_class_custom'], $argument_map_per_display[$display_name]); + } + if (!empty($display['display_options']['style']['options']['col_class_custom'])) { + $display['display_options']['style']['options']['col_class_custom'] = _views_update_8002_token_update($display['display_options']['style']['options']['col_class_custom'], $argument_map_per_display[$display_name]); + } + } + } + + if ($changed) { + $view->set('display', $displays); + $view->save(TRUE); + } + } +} + +/** + * Updates a views configuration string from using %/! to twig tokens. + * + * @param string $text + * Text in which to search for argument tokens and replace them with their + * twig representation. + * + * @return string + * The updated value. + */ +function _views_update_8002_token_update($text, array $argument_map) { + $text = preg_replace_callback('/%(\d)/', function ($match) use ($argument_map) { + return "{{ arguments.{$argument_map[$match[1]]} }}"; + }, $text); + $text = preg_replace_callback('/!(\d)/', function ($match) use ($argument_map) { + return "{{ raw_arguments.{$argument_map[$match[1]]} }}"; + }, $text); + + return $text; +} + +/** + * Updates argument maps. + * + * @param array $displays + * + * @return array + * The argument map. + */ +function _views_update_argument_map($displays) { + $argument_map = []; + foreach ($displays as $display_id => $display) { + $argument_map[$display_id] = []; + if (isset($display['display_options']['arguments'])) { + foreach (array_keys($display['display_options']['arguments']) as $number => $name) { + $argument_map[$display_id][$number + 1] = $name; + } + } + elseif (isset($displays['default']['display_options']['arguments'])) { + foreach (array_keys($displays['default']['display_options']['arguments']) as $number => $name) { + $argument_map[$display_id][$number + 1] = $name; + } + } + } + + return $argument_map; +} + +/** * @} End of "addtogroup updates-8.0.0-beta". */ + diff --git a/core/modules/views_ui/src/Tests/XssTest.php b/core/modules/views_ui/src/Tests/XssTest.php index a847434..33a7fa6 100644 --- a/core/modules/views_ui/src/Tests/XssTest.php +++ b/core/modules/views_ui/src/Tests/XssTest.php @@ -29,8 +29,8 @@ public function testViewsUi() { $this->assertEscaped('test', 'Field admin label is properly escaped.'); $this->drupalGet('admin/structure/views/nojs/handler/sa_contrib_2013_035/page_1/header/area'); - $this->assertRaw('[title] == <marquee>test</marquee>', 'Token label is properly escaped.'); - $this->assertRaw('[title_1] == <script>alert("XSS")</script>', 'Token label is properly escaped.'); + $this->assertRaw('{{ title }} == <marquee>test</marquee>', 'Token label is properly escaped.'); + $this->assertRaw('{{ title_1 }} == <script>alert("XSS")</script>', 'Token label is properly escaped.'); } }