diff --git a/core/modules/file/config/install/views.view.files.yml b/core/modules/file/config/install/views.view.files.yml index 711efee2ea33..550a9cc5ad2f 100644 --- a/core/modules/file/config/install/views.view.files.yml +++ b/core/modules/file/config/install/views.view.files.yml @@ -489,7 +489,7 @@ display: alter_text: false text: '' make_link: true - path: 'admin/content/files/usage/[fid]' + path: 'admin/content/files/usage/{{fid}}' absolute: false external: false replace_spaces: false diff --git a/core/modules/views/src/Plugin/views/PluginBase.php b/core/modules/views/src/Plugin/views/PluginBase.php index 831d60876264..e09545fb0a51 100644 --- a/core/modules/views/src/Plugin/views/PluginBase.php +++ b/core/modules/views/src/Plugin/views/PluginBase.php @@ -319,6 +319,57 @@ abstract class PluginBase extends ComponentPluginBase implements ContainerFactor return \Drupal::token()->replace($string, array('view' => $this->view), $options); } + /** + * Replaces Views' tokens in a given string. It is the responsibility of the + * calling function to ensure $text and $token replacements are sanitized. + * + * 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 + * String with possible tokens. + * @param $tokens + * Array of token => replacement_value items. + * + * @return String + */ + protected function viewsTokenReplace($text, $tokens) { + if (empty($tokens)) { + return $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) { + if (strpos($token, '{{') !== FALSE) { + // Twig wants a token replacement array stripped of curly-brackets. + $token = trim(str_replace(array('{', '}'), '', $token)); + $twig_tokens[$token] = $replacement; + } + else { + $other_tokens[$token] = $replacement; + } + } + + // 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) { + $build = array( + '#type' => 'inline_template', + '#template' => $text, + '#context' => $twig_tokens, + ); + return drupal_render($build); + } + else { + return $text; + } + } + /** * {@inheritdoc} */ diff --git a/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php b/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php index ce4c25b00631..496873f79553 100644 --- a/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php +++ b/core/modules/views/src/Plugin/views/display/DisplayPluginBase.php @@ -2136,7 +2136,7 @@ abstract class DisplayPluginBase extends PluginBase { if ($this->getOption('link_display') == 'custom_url' && $override_path = $this->getOption('link_url')) { $tokens = $this->getArgumentsTokens(); - $path = strtr($override_path, $tokens); + $path = $this->viewsTokenReplace($override_path, $tokens); } if ($path) { diff --git a/core/modules/views/src/Plugin/views/field/Field.php b/core/modules/views/src/Plugin/views/field/Field.php index 621dcac34a16..bc1312714d00 100644 --- a/core/modules/views/src/Plugin/views/field/Field.php +++ b/core/modules/views/src/Plugin/views/field/Field.php @@ -895,7 +895,7 @@ class Field extends FieldPluginBase implements CacheablePluginInterface, MultiIt protected function documentSelfTokens(&$tokens) { $field = $this->getFieldDefinition(); foreach ($field->getColumns() as $id => $column) { - $tokens['[' . $this->options['id'] . '-' . $id . ']'] = $this->t('Raw @column', array('@column' => $id)); + $tokens['{{ ' . $this->options['id'] . '-' . $id . ' }}'] = $this->t('Raw @column', array('@column' => $id)); } } @@ -913,11 +913,11 @@ class Field extends FieldPluginBase implements CacheablePluginInterface, MultiIt (is_object($item['raw']) ? (array)$item['raw'] : NULL); } if (isset($raw) && isset($raw[$id]) && is_scalar($raw[$id])) { - $tokens['[' . $this->options['id'] . '-' . $id . ']'] = Xss::filterAdmin($raw[$id]); + $tokens['{{ ' . $this->options['id'] . '-' . $id . ' }}'] = Xss::filterAdmin($raw[$id]); } else { // Make sure that empty values are replaced as well. - $tokens['[' . $this->options['id'] . '-' . $id . ']'] = ''; + $tokens['{{ ' . $this->options['id'] . '-' . $id . ' }}'] = ''; } } } diff --git a/core/modules/views/src/Plugin/views/field/FieldPluginBase.php b/core/modules/views/src/Plugin/views/field/FieldPluginBase.php index b2b6b4c61009..6066d9d6332a 100644 --- a/core/modules/views/src/Plugin/views/field/FieldPluginBase.php +++ b/core/modules/views/src/Plugin/views/field/FieldPluginBase.php @@ -322,7 +322,7 @@ abstract class FieldPluginBase extends HandlerBase implements FieldHandlerInterf * {@inheritdoc} */ public function tokenizeValue($value, $row_index = NULL) { - if (strpos($value, '[') !== FALSE || strpos($value, '!') !== FALSE || strpos($value, '%') !== FALSE) { + if (strpos($value, '{{') !== FALSE || strpos($value, '!') !== FALSE || strpos($value, '%') !== FALSE) { $fake_item = array( 'alter_text' => TRUE, 'text' => $value, @@ -705,7 +705,7 @@ abstract class FieldPluginBase extends HandlerBase implements FieldHandlerInterf '#title' => $this->t('Text'), '#type' => 'textarea', '#default_value' => $this->options['alter']['text'], - '#description' => $this->t('The text to display for this field. You may include HTML. You may enter data from this view as per the "Replacement patterns" below.'), + '#description' => $this->t('The text to display for this field. You may include HTML or Twig. You may enter data from this view as per the "Replacement patterns" below.'), '#states' => array( 'visible' => array( ':input[name="options[alter][alter_text]"]' => array('checked' => TRUE), @@ -852,10 +852,10 @@ abstract class FieldPluginBase extends HandlerBase implements FieldHandlerInterf // Setup the tokens for fields. $previous = $this->getPreviousFieldLabels(); foreach ($previous as $id => $label) { - $options[t('Fields')]["[$id]"] = substr(strrchr($label, ":"), 2 ); + $options[t('Fields')]["{{ $id }}"] = substr(strrchr($label, ":"), 2 ); } // Add the field to the list of options. - $options[t('Fields')]["[{$this->options['id']}]"] = substr(strrchr($this->adminLabel(), ":"), 2 ); + $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) { @@ -869,7 +869,7 @@ abstract class FieldPluginBase extends HandlerBase implements FieldHandlerInterf $output = '
' . $this->t('You must add some additional fields to this display before using this field. These fields may be marked as Exclude from display if you prefer. Note that due to rendering order, you cannot use fields that come after this field; if you need a field not listed here, rearrange your fields.') . '
'; // We have some options, so make a list. if (!empty($options)) { - $output = '' . $this->t("The following tokens are available for this field. Note that due to rendering order, you cannot use fields that come after this field; if you need a field not listed here, rearrange your fields. 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.") . '
'; + $output = '' . $this->t("The following Twig replacement tokens are available for this field. Note that due to rendering order, you cannot use fields that come after this field; if you need a field not listed here, rearrange your fields.") . '
'; foreach (array_keys($options) as $type) { if (!empty($options[$type])) { $items = array(); @@ -1229,7 +1229,7 @@ abstract class FieldPluginBase extends HandlerBase implements FieldHandlerInterf $more_link_text = $this->options['alter']['more_link_text'] ? $this->options['alter']['more_link_text'] : $this->t('more'); $more_link_text = strtr(Xss::filterAdmin($more_link_text), $tokens); $more_link_path = $this->options['alter']['more_link_path']; - $more_link_path = strip_tags(String::decodeEntities(strtr($more_link_path, $tokens))); + $more_link_path = strip_tags(String::decodeEntities($this->viewsTokenReplace($more_link_path, $tokens))); // Make sure that paths which were run through _url() work as well. $base_path = base_path(); @@ -1260,14 +1260,12 @@ abstract class FieldPluginBase extends HandlerBase implements FieldHandlerInterf } /** - * Render this field as altered text, from a fieldset set by the user. + * Render this field as user-defined altered text. */ protected function renderAltered($alter, $tokens) { // Filter this right away as our substitutions are already sanitized. - $value = Xss::filterAdmin($alter['text']); - $value = strtr($value, $tokens); - - return $value; + $template = Xss::filterAdmin($alter['text']); + return $this->viewsTokenReplace($template, $tokens); } /** @@ -1290,7 +1288,7 @@ abstract class FieldPluginBase extends HandlerBase implements FieldHandlerInterf $value = ''; if (!empty($alter['prefix'])) { - $value .= Xss::filterAdmin(strtr($alter['prefix'], $tokens)); + $value .= Xss::filterAdmin($this->viewsTokenReplace($alter['prefix'], $tokens)); } $options = array( @@ -1311,7 +1309,7 @@ abstract class FieldPluginBase extends HandlerBase implements FieldHandlerInterf // Use strip tags as there should never be HTML in the path. // However, we need to preserve special characters like " that // were removed by String::checkPlain(). - $path = strip_tags(String::decodeEntities(strtr($path, $tokens))); + $path = strip_tags(String::decodeEntities($this->viewsTokenReplace($path, $tokens))); if (!empty($alter['path_case']) && $alter['path_case'] != 'none') { $path = $this->caseTransform($path, $this->options['alter']['path_case']); @@ -1380,22 +1378,22 @@ abstract class FieldPluginBase extends HandlerBase implements FieldHandlerInterf $options['fragment'] = $url['fragment']; } - $alt = strtr($alter['alt'], $tokens); + $alt = $this->viewsTokenReplace($alter['alt'], $tokens); // Set the title attribute of the link only if it improves accessibility if ($alt && $alt != $text) { $options['attributes']['title'] = String::decodeEntities($alt); } - $class = strtr($alter['link_class'], $tokens); + $class = $this->viewsTokenReplace($alter['link_class'], $tokens); if ($class) { $options['attributes']['class'] = array($class); } - if (!empty($alter['rel']) && $rel = strtr($alter['rel'], $tokens)) { + if (!empty($alter['rel']) && $rel = $this->viewsTokenReplace($alter['rel'], $tokens)) { $options['attributes']['rel'] = $rel; } - $target = String::checkPlain(trim(strtr($alter['target'], $tokens))); + $target = String::checkPlain(trim($this->viewsTokenReplace($alter['target'], $tokens))); if (!empty($target)) { $options['attributes']['target'] = $target; } @@ -1405,7 +1403,7 @@ abstract class FieldPluginBase extends HandlerBase implements FieldHandlerInterf if (isset($alter['link_attributes']) && is_array($alter['link_attributes'])) { foreach ($alter['link_attributes'] as $key => $attribute) { if (!isset($options['attributes'][$key])) { - $options['attributes'][$key] = strtr($attribute, $tokens); + $options['attributes'][$key] = $this->viewsTokenReplace($attribute, $tokens); } } } @@ -1416,7 +1414,7 @@ abstract class FieldPluginBase extends HandlerBase implements FieldHandlerInterf // Convert the query to a string, perform token replacement, and then // convert back to an array form for _l(). $options['query'] = UrlHelper::buildQuery($alter['query']); - $options['query'] = strtr($options['query'], $tokens); + $options['query'] = $this->viewsTokenReplace($options['query'], $tokens); $query = array(); parse_str($options['query'], $query); $options['query'] = $query; @@ -1426,7 +1424,7 @@ abstract class FieldPluginBase extends HandlerBase implements FieldHandlerInterf $options['alias'] = $alter['alias']; } if (isset($alter['fragment'])) { - $options['fragment'] = strtr($alter['fragment'], $tokens); + $options['fragment'] = $this->viewsTokenReplace($alter['fragment'], $tokens); } if (isset($alter['language'])) { $options['language'] = $alter['language']; @@ -1448,7 +1446,7 @@ abstract class FieldPluginBase extends HandlerBase implements FieldHandlerInterf } if (!empty($alter['suffix'])) { - $value .= Xss::filterAdmin(strtr($alter['suffix'], $tokens)); + $value .= Xss::filterAdmin($this->viewsTokenReplace($alter['suffix'], $tokens)); } return $value; @@ -1481,10 +1479,10 @@ abstract class FieldPluginBase extends HandlerBase implements FieldHandlerInterf // Now add replacements for our fields. foreach ($this->view->display_handler->getHandlers('field') as $field => $handler) { if (isset($handler->last_render)) { - $tokens["[$field]"] = $handler->last_render; + $tokens["{{ $field }}"] = $handler->last_render; } else { - $tokens["[$field]"] = ''; + $tokens["{{ $field }}"] = ''; } // We only use fields up to (and including) this one. @@ -1568,9 +1566,10 @@ abstract class FieldPluginBase extends HandlerBase implements FieldHandlerInterf * fields as a list. For example, the field that displays all terms * on a node might have tokens for the tid and the term. * - * By convention, tokens should follow the format of [token-subtoken] + * By convention, tokens should follow the format of {{ token-subtoken }} * where token is the field ID and subtoken is the field. If the - * field ID is terms, then the tokens might be [terms-tid] and [terms-name]. + * field ID is terms, then the tokens might be {{ terms-tid }} and + * {{ terms-name }}. */ protected function addSelfTokens(&$tokens, $item) { } diff --git a/core/modules/views/src/Plugin/views/field/Links.php b/core/modules/views/src/Plugin/views/field/Links.php index b8f4098dd010..cf06b6295022 100644 --- a/core/modules/views/src/Plugin/views/field/Links.php +++ b/core/modules/views/src/Plugin/views/field/Links.php @@ -82,7 +82,7 @@ abstract class Links extends FieldPluginBase { } // Make sure that tokens are replaced for this paths as well. $tokens = $this->getRenderTokens(array()); - $path = strip_tags(String::decodeEntities(strtr($path, $tokens))); + $path = strip_tags(String::decodeEntities($this->viewsTokenReplace($path, $tokens))); $links[$field] = array( 'url' => $path ? UrlObject::fromUri('base://' . $path) : $url, diff --git a/core/modules/views/src/Plugin/views/style/StylePluginBase.php b/core/modules/views/src/Plugin/views/style/StylePluginBase.php index aa2d8e8b5706..9c229da0c63c 100644 --- a/core/modules/views/src/Plugin/views/style/StylePluginBase.php +++ b/core/modules/views/src/Plugin/views/style/StylePluginBase.php @@ -191,7 +191,7 @@ abstract class StylePluginBase extends PluginBase { 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 || strpos($class, '!') !== FALSE || strpos($class, '%') !== FALSE) { return TRUE; } } @@ -228,18 +228,15 @@ abstract class StylePluginBase extends PluginBase { * 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 || strpos($value, '!') !== FALSE || 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'])) { $tokens += $this->view->build_info['substitutions']; } - if ($tokens) { - $value = strtr($value, $tokens); - } + $value = $this->viewsTokenReplace($value, $tokens); } - return $value; } diff --git a/core/modules/views/src/Tests/Handler/FieldUnitTest.php b/core/modules/views/src/Tests/Handler/FieldUnitTest.php index 12fe74e78805..f7bb93221403 100644 --- a/core/modules/views/src/Tests/Handler/FieldUnitTest.php +++ b/core/modules/views/src/Tests/Handler/FieldUnitTest.php @@ -164,13 +164,13 @@ class FieldUnitTest extends ViewUnitTestBase { $row = $view->result[0]; $name_field_0->options['alter']['alter_text'] = TRUE; - $name_field_0->options['alter']['text'] = '[name]'; + $name_field_0->options['alter']['text'] = '{{ name }}'; $name_field_1->options['alter']['alter_text'] = TRUE; - $name_field_1->options['alter']['text'] = '[name_1] [name]'; + $name_field_1->options['alter']['text'] = '{{ name_1 }} {{ name }}'; $name_field_2->options['alter']['alter_text'] = TRUE; - $name_field_2->options['alter']['text'] = '[name_2] [name_1]'; + $name_field_2->options['alter']['text'] = '{{ name_2 }} {{ name_1 }}'; foreach ($view->result as $row) { $expected_output_0 = $row->views_test_data_name; @@ -178,23 +178,48 @@ class FieldUnitTest extends ViewUnitTestBase { $expected_output_2 = "$row->views_test_data_name $row->views_test_data_name $row->views_test_data_name"; $output = $name_field_0->advancedRender($row); - $this->assertEqual($output, $expected_output_0); + $this->assertEqual($output, $expected_output_0, format_string('Test token replacement: "!token" gave "!output"', [ + '!token' => $name_field_0->options['alter']['text'], + '!output' => $output, + ])); $output = $name_field_1->advancedRender($row); - $this->assertEqual($output, $expected_output_1); + $this->assertEqual($output, $expected_output_1, format_string('Test token replacement: "!token" gave "!output"', [ + '!token' => $name_field_1->options['alter']['text'], + '!output' => $output, + ])); $output = $name_field_2->advancedRender($row); - $this->assertEqual($output, $expected_output_2); + $this->assertEqual($output, $expected_output_2, format_string('Test token replacement: "!token" gave "!output"', [ + '!token' => $name_field_2->options['alter']['text'], + '!output' => $output, + ])); } $job_field = $view->field['job']; $job_field->options['alter']['alter_text'] = TRUE; - $job_field->options['alter']['text'] = '[test-token]'; + $job_field->options['alter']['text'] = '{{ job }}'; $random_text = $this->randomMachineName(); $job_field->setTestValue($random_text); $output = $job_field->advancedRender($row); - $this->assertSubString($output, $random_text, format_string('Make sure the self token (!value) appears in the output (!output)', array('!value' => $random_text, '!output' => $output))); + $this->assertSubString($output, $random_text, format_string('Make sure the self token (!token => !value) appears in the output (!output)', [ + '!value' => $random_text, + '!output' => $output, + '!token' => $job_field->options['alter']['text'], + ])); + + // Verify the token format used in D7 and earlier does not get substituted. + $old_token = '[job]'; + $job_field->options['alter']['text'] = $old_token; + $random_text = $this->randomMachineName(); + $job_field->setTestValue($random_text); + $output = $job_field->advancedRender($row); + $this->assertSubString($output, $old_token, format_string('Make sure the old token style (!token => !value) is not changed in the output (!output)', [ + '!value' => $random_text, + '!output' => $output, + '!token' => $job_field->options['alter']['text'], + ])); } /** diff --git a/core/modules/views/src/Tests/Plugin/StyleTest.php b/core/modules/views/src/Tests/Plugin/StyleTest.php index 957f8f87ad4e..da95df58fb20 100644 --- a/core/modules/views/src/Tests/Plugin/StyleTest.php +++ b/core/modules/views/src/Tests/Plugin/StyleTest.php @@ -226,7 +226,7 @@ class StyleTest extends ViewTestBase { // Setup some random css class. $view->initStyle(); $random_name = $this->randomMachineName(); - $view->style_plugin->options['row_class'] = $random_name . " test-token-[name]"; + $view->style_plugin->options['row_class'] = $random_name . " test-token-{{ name }}"; $output = $view->preview(); $this->storeViewPreview(drupal_render($output)); diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_dropbutton.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_dropbutton.yml index f55087f5bfb1..343901706c1f 100644 --- a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_dropbutton.yml +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_dropbutton.yml @@ -122,7 +122,7 @@ display: alter_text: true text: 'Custom Text' make_link: true - path: 'node/[nid]' + path: 'node/{{nid}}' absolute: false external: false replace_spaces: false diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_grid.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_grid.yml index 622ace0f45f9..d8400f7fc70d 100644 --- a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_grid.yml +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_grid.yml @@ -55,9 +55,9 @@ display: automatic_width: true alignment: horizontal col_class_default: true - col_class_custom: 'name-[name]' + col_class_custom: 'name-{{name}}' row_class_default: true - row_class_custom: 'age-[age]' + row_class_custom: 'age-{{ age }}' row: type: fields field_langcode: '***LANGUAGE_language_content***' diff --git a/core/modules/views_ui/src/Tests/FieldUITest.php b/core/modules/views_ui/src/Tests/FieldUITest.php index fac6e466cf70..81d2b7e94d13 100644 --- a/core/modules/views_ui/src/Tests/FieldUITest.php +++ b/core/modules/views_ui/src/Tests/FieldUITest.php @@ -43,20 +43,20 @@ class FieldUITest extends UITestBase { $edit_handler_url = 'admin/structure/views/nojs/handler/test_view/default/field/age'; $this->drupalGet($edit_handler_url); $result = $this->xpath('//details[@id="edit-options-alter-help"]/div[@class="details-wrapper"]/div[@class="item-list"]/fields/li'); - $this->assertEqual((string) $result[0], '[age] == Age'); + $this->assertEqual((string) $result[0], '{{ age }} == Age'); $edit_handler_url = 'admin/structure/views/nojs/handler/test_view/default/field/id'; $this->drupalGet($edit_handler_url); $result = $this->xpath('//details[@id="edit-options-alter-help"]/div[@class="details-wrapper"]/div[@class="item-list"]/fields/li'); - $this->assertEqual((string) $result[0], '[age] == Age'); - $this->assertEqual((string) $result[1], '[id] == ID'); + $this->assertEqual((string) $result[0], '{{ age }} == Age'); + $this->assertEqual((string) $result[1], '{{ id }} == ID'); $edit_handler_url = 'admin/structure/views/nojs/handler/test_view/default/field/name'; $this->drupalGet($edit_handler_url); $result = $this->xpath('//details[@id="edit-options-alter-help"]/div[@class="details-wrapper"]/div[@class="item-list"]/fields/li'); - $this->assertEqual((string) $result[0], '[age] == Age'); - $this->assertEqual((string) $result[1], '[id] == ID'); - $this->assertEqual((string) $result[2], '[name] == Name'); + $this->assertEqual((string) $result[0], '{{ age }} == Age'); + $this->assertEqual((string) $result[1], '{{ id }} == ID'); + $this->assertEqual((string) $result[2], '{{ name }} == Name'); } /**