Commit 552c86ca authored by alexpott's avatar alexpott

Issue #2342287 by mikeker: Allow Twig in Views token replacement

parent 29f441d3
......@@ -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
......
......@@ -319,6 +319,57 @@ public function globalTokenReplace($string = '', array $options = array()) {
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}
*/
......
......@@ -2136,7 +2136,7 @@ public function renderMoreLink() {
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) {
......
......@@ -895,7 +895,7 @@ function render_item($count, $item) {
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 @@ protected function addSelfTokens(&$tokens, $item) {
(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 . ' }}'] = '';
}
}
}
......
......@@ -322,7 +322,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 || strpos($value, '!') !== FALSE || strpos($value, '%') !== FALSE) {
$fake_item = array(
'alter_text' => TRUE,
'text' => $value,
......@@ -705,7 +705,7 @@ public function buildOptionsForm(&$form, FormStateInterface $form_state) {
'#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 @@ public function buildOptionsForm(&$form, FormStateInterface $form_state) {
// 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 @@ public function buildOptionsForm(&$form, FormStateInterface $form_state) {
$output = '<p>' . $this->t('You must add some additional fields to this display before using this field. These fields may be marked as <em>Exclude from display</em> 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.') . '</p>';
// We have some options, so make a list.
if (!empty($options)) {
$output = '<p>' . $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.") . '</p>';
$output = '<p>' . $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.") . '</p>';
foreach (array_keys($options) as $type) {
if (!empty($options[$type])) {
$items = array();
......@@ -1229,7 +1229,7 @@ public function renderText($alter) {
$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 @@ public function renderText($alter) {
}
/**
* 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 @@ protected function renderAsLink($alter, $text, $tokens) {
$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 @@ protected function renderAsLink($alter, $text, $tokens) {
// 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 @@ protected function renderAsLink($alter, $text, $tokens) {
$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 @@ protected function renderAsLink($alter, $text, $tokens) {
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 @@ protected function renderAsLink($alter, $text, $tokens) {
// 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 @@ protected function renderAsLink($alter, $text, $tokens) {
$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 @@ protected function renderAsLink($alter, $text, $tokens) {
}
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 @@ public function getRenderTokens($item) {
// 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 @@ protected function getTokenValuesRecursive(array $array, array $parent_keys = ar
* 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) { }
......
......@@ -82,7 +82,7 @@ protected function getLinks() {
}
// 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,
......
......@@ -191,7 +191,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 || strpos($class, '!') !== FALSE || strpos($class, '%') !== FALSE) {
return TRUE;
}
}
......@@ -228,18 +228,15 @@ 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 || 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;
}
......
......@@ -164,13 +164,13 @@ public function testFieldTokens() {
$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 @@ public function testFieldTokens() {
$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'],
]));
}
/**
......
......@@ -226,7 +226,7 @@ function testCustomRowClasses() {
// 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));
......
......@@ -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
......
......@@ -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***'
......
......@@ -43,20 +43,20 @@ public function testFieldUI() {
$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');
}
/**
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment