Beta phase evaluation

Reference: https://www.drupal.org/core/beta-changes
Issue category Bug: Rewriting Views results is currently broken for multi-valued fields
Issue priority Major because this is a very commonly used feature.
Prioritized changes Improves site builder experience. Potentially improves security as site builders may enable Views PHP/PHP Filter to avoid writing custom field or style plugins. (At least I have! :)
Disruption This will affect any module that implements a Views field/style plugin and provides tokens, though the change is minor: replacing [ ] with {{ }}. It will also affect importing existing Views into D8 if the BC layers is removed.

Problem/Motivation

Views allows its output to be rewritten using [field_name] style tokens via str_replace(). These replacements are currently broken due to the value being double-escaped.

Proposed resolution

Convert Views' token replacement code to Twig. This would fix the double-escaped error plus allow site builders much greater flexibility in rewriting Views' output.

For example, a site builder can now use the value of one field to determine the pluralization of another. Previously, this required either a custom field handler or enabling the Views PHP module.

Remaining tasks

User interface changes

The "Replacement patterns" section of the Views UI would need to be updated as well as the description text where Twig replacement patterns can be used.

API changes

Views would internally store field tokens in Twig format, eg: {{ field_name }} instead of [field_name]. Modules that implement field or style plugins that use tokens would need to update to the new format.

Original report by @andile2012

Problem/Motivation

In a view, I rewrote the "Tags" field has HTML <h2>[field_tags]</h2>, but it is just rendering as plain text, not HTML. I also tried to group by this field, and I'm still just getting plain text. I have exported my view and placed it below. Would appreciate any ideas about what to do next.

uuid: d13f4da0-7286-429e-ae54-84dd92eb9688
langcode: en
status: true
dependencies:
  entity:
    - field.storage.node.body
    - field.storage.node.field_tags
  module:
    - field
    - node
id: test_of_grouping
label: 'Test of grouping'
module: views
description: ''
tag: ''
base_table: node
base_field: nid
core: 8.x
display:
  default:
    display_plugin: default
    id: default
    display_title: Master
    position: 0
    provider: views
    display_options:
      access:
        type: perm
        options:
          perm: 'access content'
        provider: user
        dependencies: {  }
      cache:
        type: none
        options: {  }
        provider: views
        dependencies: {  }
      query:
        type: views_query
        options:
          disable_sql_rewrite: false
          distinct: false
          replica: false
          query_comment: false
          query_tags: {  }
        provider: views
        dependencies: {  }
      exposed_form:
        type: basic
        options:
          submit_button: Apply
          reset_button: false
          reset_button_label: Reset
          exposed_sorts_label: 'Sort by'
          expose_sort_order: true
          sort_asc_label: Asc
          sort_desc_label: Desc
        provider: views
        dependencies: {  }
      pager:
        type: none
        options:
          items_per_page: null
          offset: 0
      style:
        type: default
        options:
          grouping:
            -
              field: field_tags
              rendered: true
              rendered_strip: false
          row_class: ''
          default_row_class: true
          uses_fields: false
      row:
        type: fields
        options:
          default_field_elements: true
          inline: {  }
          separator: ''
          hide_empty: false
        provider: views
      fields:
        title:
          id: title
          table: node_field_data
          field: title
          provider: node
          label: ''
          alter:
            alter_text: false
            make_link: false
            absolute: false
            trim: false
            word_boundary: false
            ellipsis: false
            strip_tags: false
            html: false
          hide_empty: false
          empty_zero: false
          link_to_node: 1
          relationship: none
          group_type: group
          admin_label: ''
          dependencies: {  }
          exclude: false
          element_type: ''
          element_class: ''
          element_label_type: ''
          element_label_class: ''
          element_label_colon: true
          element_wrapper_type: ''
          element_wrapper_class: ''
          element_default_classes: true
          empty: ''
          hide_alter_empty: true
        body:
          id: body
          table: node__body
          field: body
          relationship: none
          group_type: group
          admin_label: ''
          dependencies:
            entity:
              - field.storage.node.body
            module:
              - field
          label: ''
          exclude: false
          alter:
            alter_text: false
            text: ''
            make_link: false
            path: ''
            absolute: false
            external: false
            replace_spaces: false
            path_case: none
            trim_whitespace: false
            alt: ''
            rel: ''
            link_class: ''
            prefix: ''
            suffix: ''
            target: ''
            nl2br: false
            max_length: ''
            word_boundary: true
            ellipsis: true
            more_link: false
            more_link_text: ''
            more_link_path: ''
            strip_tags: false
            trim: false
            preserve_tags: ''
            html: false
          element_type: ''
          element_class: ''
          element_label_type: ''
          element_label_class: ''
          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: text_default
          settings: {  }
          group_column: value
          group_columns: {  }
          group_rows: true
          delta_limit: all
          delta_offset: '0'
          delta_reversed: false
          delta_first_last: false
          multi_type: separator
          separator: ', '
          field_api_classes: false
          plugin_id: field
          provider: field
        field_tags:
          id: field_tags
          table: node__field_tags
          field: field_tags
          relationship: none
          group_type: group
          admin_label: ''
          dependencies:
            entity:
              - field.storage.node.field_tags
            module:
              - field
          label: ''
          exclude: true
          alter:
            alter_text: true
            text: '<h2>[field_tags]</h2>'
            make_link: false
            path: ''
            absolute: false
            external: false
            replace_spaces: false
            path_case: none
            trim_whitespace: false
            alt: ''
            rel: ''
            link_class: ''
            prefix: ''
            suffix: ''
            target: ''
            nl2br: false
            max_length: ''
            word_boundary: true
            ellipsis: true
            more_link: false
            more_link_text: ''
            more_link_path: ''
            strip_tags: false
            trim: false
            preserve_tags: ''
            html: false
          element_type: ''
          element_class: ''
          element_label_type: ''
          element_label_class: ''
          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: target_id
          type: taxonomy_term_reference_link
          settings: {  }
          group_column: target_id
          group_columns: {  }
          group_rows: true
          delta_limit: all
          delta_offset: '0'
          delta_reversed: false
          delta_first_last: false
          multi_type: separator
          separator: ', '
          field_api_classes: false
          plugin_id: field
          provider: field
      filters:
        status:
          value: true
          table: node_field_data
          field: status
          provider: node
          id: status
          expose:
            operator: ''
          group: 1
        type:
          id: type
          table: node
          field: type
          value:
            article: article
      sorts:
        created:
          id: created
          table: node_field_data
          field: created
          order: DESC
          relationship: none
          group_type: group
          admin_label: ''
          dependencies: {  }
          exposed: false
          expose:
            label: ''
          granularity: second
      title: 'Test of grouping'
      header: {  }
      footer: {  }
      empty: {  }
      relationships: {  }
      arguments: {  }
      field_langcode: '***LANGUAGE_language_content***'
      field_langcode_add_to_query: null
  page_1:
    display_plugin: page
    id: page_1
    display_title: Page
    position: 1
    provider: views
    display_options:
      field_langcode: '***LANGUAGE_language_content***'
      field_langcode_add_to_query: null
      path: test-of-grouping
Support from Acquia helps fund testing for Drupal Acquia logo

Comments

dawehner’s picture

Priority: Normal » Major

Sounds like a pretty major bug.

mikeker’s picture

Assigned: Unassigned » mikeker

I'll be working on this at the Amsterdam sprints.

dawehner’s picture

@mikeker
Let's say at least hi :)

mikeker’s picture

With all the changes around SafeMarkup::set(), I'd appreciate someone telling me if this is a valid way to fix this...

mikeker’s picture

Status: Active » Needs review
mikeker’s picture

Issue tags: +Needs tests

Note: unit tests are (in my opinion) outside of the scope of this issue because of #2346615: Views doesn't have multi-valued field unit tests.

mikeker’s picture

Just talked with @dawehner and we agreed that this would be better fixed by rewriting Views' rewrite option to use inline Twig templates instead of a textarea powered by str_replace().

Patch coming soon...

mikeker’s picture

Here's the quick plan after feedback from @joelpittet:

  • Views UI remains basically the same: checkbox to override result with custom text, replacement values from this result row are available in square brackets (eg: [field_name])
  • Add note in the UI that Twig formatting is available
  • On render:
    • str_replace() the square bracket with double-curly brackets
    • Pass what remains to Twig as an inline template using the Views replacement patterns as a context

Ideally any D7 view that currently uses the rewrite option would work without change. But it adds the ability for people to use all the fun features in Twig, along with Twig's autoescaping and safe-by-default approach, to rewrite to their heart's content.

Going forward we could give the option to use a file-based template instead of an inline one.

mikeker’s picture

Attached patch does what's outlined in #8.

Status: Needs review » Needs work

The last submitted patch, 9: 2342287-9-views-rewrite-as-twig.patch, failed testing.

mikeker’s picture

Issue tags: +Amsterdam2014

Just noticed the tag for Amsterdam sprint issues...

mikeker’s picture

Status: Needs work » Needs review
Issue tags: -Needs tests
FileSize
8.45 KB

Added tests so both D7 style ([field_name]) and Twig style ({{ field_name }}) rewrites are covered.

Wuk’s picture

I tried last patch from #12, and it seems that with this patch the issue is fixed and it's working as described in #8.

To test patch I rewrote the "Body" field as <h2>[body]</h2>, which without patch resulted as:

<td class="views-field views-field-body" headers="view-body-table-column">
  &lt;h2&gt;&lt;p&gt;Body test&lt;/p&gt;&lt;/h2&gt;
</td>

and rendered as plain text in browser:

<h2><p>Body test</p></h2> 

With patch result is as expected:

<td class="views-field views-field-body" headers="view-body-table-column">
  <h2><p>Body test</p></h2>
</td>

and rendered in browser as expected:

Body test

Also tried with twig template syntax

<p>{% for i in 0..10 %}
    * {{ i }}
{% endfor %}</p>
{{ body }}

which resulted as expected:

<td class="views-field views-field-body" headers="view-body-table-column">
  <p> * 0 * 1 * 2 * 3 * 4 * 5 * 6 * 7 * 8 * 9 * 10 </p>
  <p>Body test</p>
</td>

and rendered in browser as expected:

 * 0 * 1 * 2 * 3 * 4 * 5 * 6 * 7 * 8 * 9 * 10
Body test
dawehner’s picture

I really like the idea, but yeah let's skip the BC layer.


+++ b/core/modules/views/src/Plugin/views/field/FieldPluginBase.php
@@ -1306,14 +1306,34 @@ public function renderText($alter) {
+    // For backward compatibility, we convert square brackets to double-curly
+    // brackets so that "[field_name]" becomes "{{ field_name }}".
+    $replacements = [];
+    $context = [];
...
+      $token_name = substr($square_token, 1, -1);
+
+      // Twig cannot have '-' characters in variable names, but D7-style tokens
+      // can. Convert them to '_' for backward compatibility.
+      $token_name = str_replace('-', '_', $token_name);
+
+      $replacements[$square_token] = "{{ $token_name }}";
+      $context[$token_name] = $tokens[$square_token];
+    }
+    $template = strtr($template, $replacements);

Please let's us not take care about BC at this point.

mikeker’s picture

@Wuk, Thank you for testing the patch and sharing your results.

@dawehner, I agree, backward compatibility makes the code a bit more complicated, but not overly so and I can't think of any way it would impact performance. And it simplifies the upgrade/migrate path. But I'm happy to pull it from the patch if you don't agree it's worth it.

If we remove backward compatibility, we would have to change Views' token system to replace [token-value] with {{token_value}}. Are there any other places in Views that we use token replacement? (I can't think of any off the top of my head...) If there are, perhaps we should convert these to Twig-style rewrites as well? It would be good to get all of them at once!

dawehner’s picture

If we remove backward compatibility, we would have to change Views' token system to replace [token-value] with {{token_value}}. Are there any other places in Views that we use token replacement? (I can't think of any off the top of my head...) If there are, perhaps we should convert these to Twig-style rewrites as well? It would be good to get all of them at once!

Mh you are right ... there are though places like in Entity area handlers where you can use the tokens coming from arguments, mhhhhhhh

dawehner’s picture

So let's remove the BC and use different kind of tokens for fields than for the other stuff.

Berdir’s picture

Looks like a duplicate of #2337747: HTML entities/tags in the item title are double encoded for feeds using fields, has the same fix from what I can see.

dawehner’s picture

Looks like a duplicate of #2337747: HTML entities in the item title are double encoded for feeds using fields, has the same fix from what I can see.

Huch? This is not the same fix ... given that this issue introduces the suage of twig inside views.

mikeker’s picture

Title: Views rewrite results not rendering HTML » Allow Twig for Views token replacement
Issue summary: View changes
Status: Needs review » Needs work
FileSize
12.99 KB

Added issue summary.

mikeker’s picture

Title: Allow Twig for Views token replacement » Allow Twig in Views token replacement
mikeker’s picture

Issue summary: View changes

Added Beta Eval to the issue summary.

mikeker’s picture

Status: Needs work » Needs review
FileSize
13.71 KB

Attached patch aims to add Twig to all tokenized fields in Views. Work-in-progress, hoping to get some feedback from the testbot and others.

Status: Needs review » Needs work

The last submitted patch, 23: 2342287-23_views-rewrite-as-twig.patch, failed testing.

mikeker’s picture

Status: Needs work » Needs review

Moved the token replacement code into PluginBase and fixed a bunch of tests (updating [token] to {{token}}). Replaced most (all, hopefully) of the hidden strtr() token replacements hidden throughout the code.

mikeker’s picture

Helps to attach the patch...

Status: Needs review » Needs work

The last submitted patch, 26: 2342287-25_views-rewrite-as-twig.patch, failed testing.

mikeker’s picture

Status: Needs work » Needs review
FileSize
24.79 KB
585 bytes

Fixes the last failing test.

Berdir’s picture

Re. #18/#19: Sorry yes, the first patch was the same, now we're going for a different solution. Anyway, my conclusion is still correct I think, if we replace that initial fix with the twig replacements, then it might also fix the other issue which needed the same fix?

mikeker’s picture

@Berdir: now that the testbot is green, I'll see if it fixes #2337747: HTML entities/tags in the item title are double encoded for feeds using fields and a few other rewrite issues I saw in the issue queue.

Wuk’s picture

I did same as in #13 using twig syntax and it works as expected.

mikeker’s picture

@Berdir: No dice... The patch does not fix the issue raised in #2337747: HTML entities/tags in the item title are double encoded for feeds using fields, unfortunately.

Berdir’s picture

dawehner’s picture

There is also #2280961: Views markup and SafeMarkup, not sure how this is related :)

That other issue is the general meta issue to get rid of all of them.

This issue fixes a particular subset of existing problems.

+++ b/core/modules/views/src/Plugin/views/PluginBase.php
@@ -293,6 +293,57 @@ public function globalTokenReplace($string = '', array $options = array()) {
+  public function viewsTokenReplace($text, $tokens) {

Just curious, is there a need to have this method to be public?

mikeker’s picture

@dawehner: Probably not... Protected would be just fine. Will adjust the patch when I get back from holidays.

mikeker’s picture

Changes public to protected as @dawehner suggestd in #34. Also cleaned up some doc typos.

Status: Needs review » Needs work

The last submitted patch, 36: 2342287-36_views-rewrite-as-twig.patch, failed testing.

mikeker’s picture

FileSize
1.35 KB

HA! I did the interdiff backwards... :)

Use this one.

mikeker’s picture

Status: Needs work » Needs review
FileSize
24.17 KB

sigh... Rerolled against the latest HEAD.

dawehner’s picture

Issue summary: View changes
Status: Needs review » Reviewed & tested by the community
Issue tags: +Upgrade path

Alright, well let's get this in, better sooner than later.

mikeker’s picture

Issue tags: -Amsterdam2014

Does this need a change record?

mikeker’s picture

In case we do, I started a draft change record.

dawehner’s picture

Status: Reviewed & tested by the community » Needs work

I fear :(

mikeker’s picture

@dawehner: details?

dawehner’s picture

Status: Needs work » Reviewed & tested by the community

OH I guess this was a comment conflict :)

alexpott’s picture

Status: Reviewed & tested by the community » Fixed

Committed 552c86c and pushed to 8.0.x. Thanks!

Thanks for adding the beta evaluation to the issue summary.

  • alexpott committed 552c86c on 8.0.x
    Issue #2342287 by mikeker: Allow Twig in Views token replacement
    
star-szr’s picture

Just want to say thanks to everyone who worked on this or helped out in any way, especially @mikeker and @dawehner! This is really exciting, to be able to use Twig filters and other Twig basics from Views UI seems very cool to me.

mikeker’s picture

@Cottser: I appreciate the kind words.

I'm really hoping this allows site builders all sorts of flexibility. The first such idea is showing up in #2055597: Allow multi-value field rendering to pass through Views rewrite only once, which started out as a request to allow different HTML around single values in multi-valued fields. There is a security concern in that issue that I would appreciate feedback on. In short, Xss::filterAdmin encodes '<' and '>' which causes Twig to barf if you have greater/less-than conditionals in your rewrite.

xjm’s picture

Need some feedback over in #2396607: Allow Views token matching for validation (and remove dead code):

+++ b/core/modules/views/src/Plugin/views/PluginBase.php
@@ -320,6 +320,57 @@ public function globalTokenReplace($string = '', array $options = array()) {
+      if (strpos($token, '{{') !== FALSE) {
+        // Twig wants a token replacement array stripped of curly-brackets.
+        $token = trim(str_replace(array('{', '}'), '', $token));

Twig supports lots and lots of things with the {{ }} operators, like:
{{ "foo #{1 + 2} baz" }}

However, the code above would silently break that. Should Views only support plain tokens like {{ foo }}? Or maybe there is a usecase to support {{ foo.bar }} or richer expressions like:
{{ user.username|e('css') }}

#2396607: Allow Views token matching for validation (and remove dead code) is adding a bit of API for Views token handling, so it would make sense to address this over there.

xjm’s picture

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

Fabianx’s picture

  1. +++ b/core/modules/views/src/Plugin/views/PluginBase.php
    @@ -320,6 +320,57 @@ public function globalTokenReplace($string = '', array $options = 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;
    +      }
    

    Post-commit review:

    This feels wrong, because while it works for simple cases, twig has many ways to get a variable and this would not allow those.

    Proposal:

    We know that a token is either %1 or @1, so replace those directly.

    Push as context all tokens except those tokens directly.

    Nice to have:

    Or maybe even make them available as a tokens variable.

    So I could write e.g.:

    {{ args[1] }} for %1

    and

    {{ tokens[1] }} for @1 or such?

  2. +++ b/core/modules/views/src/Plugin/views/PluginBase.php
    @@ -320,6 +320,57 @@ public function globalTokenReplace($string = '', array $options = 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);
    

    Overall the straight string replacement while good for keeping BC in a way is a security nightmare.

    Because if I do:

    %1{%2

    e.g. I might suddenly - given enough input possibilities - be able to dynamically change the twig template.

    And that is "Twig-Injection-Exploit"?

    I know it might not be possible, but if framework manager would allow breaking BC, the best would be to just use:

    {{ tokens['%1'] }}
    {{ tokens['@1'] }}

    instead or find some shorter variable names like:

    {{ at[1] }} -- @1
    {{ p[1] }} -- %1

    ===

    This also should at least use the new SafeReplace function from the SafeMarkup for #markup patch - once it is available.

  3. +++ b/core/modules/views/src/Plugin/views/field/Field.php
    @@ -890,7 +890,7 @@ function render_item($count, $item) {
    -      $tokens['[' . $this->options['id'] . '-' . $id . ']'] = $this->t('Raw @column', array('@column' => $id));
    +      $tokens['{{ ' . $this->options['id'] . '-' . $id . ' }}'] = $this->t('Raw @column', array('@column' => $id));
    
    @@ -908,11 +908,11 @@ protected function addSelfTokens(&$tokens, $item) {
    -        $tokens['[' . $this->options['id'] . '-' . $id . ']'] = Xss::filterAdmin($raw[$id]);
    +        $tokens['{{ ' . $this->options['id'] . '-' . $id . ' }}'] = Xss::filterAdmin($raw[$id]);
    ...
    -        $tokens['[' . $this->options['id'] . '-' . $id . ']'] = '';
    +        $tokens['{{ ' . $this->options['id'] . '-' . $id . ' }}'] = '';
    

    I think it would be enough to just have e.g.:

    $tokens[$this->options['id']] = Xss::filterAdmin($raw[$id]);

    and not deal with either '[]' or '{{' to support my comment above.

Fabianx’s picture

jonathanshaw’s picture

This issue is in imminent danger of being rolled-back prior for RC1, because #2492839: Follow-up: Convert Views' %n and !n replacement tokens to Twig syntax has not been addressed (according to #15 in that issue).