Problem/Motivation

Sometimes, you need to move a bunch of fields out of the {{ content }} array and render them elsewhere in your template.

However, you definitely have to render {{ content }} somewhere, or you don't get the expected cache tags for your output.

To avoid the fields showing up twice, you're supposed to do something like this:

  <div class="somewhere-fancy">
    {{ content.field_1 }}
    {{ content.field_2 }}
  </div>
  <div class="content">
    {{ content|without('field_1', 'field_2') }}
  </div>

That works okay if you're only dealing with a small number of fields, but sometimes you need to move *a lot* of fields elsewhere in the template. This gets clumsy.

Twig lets you iterate over an array, but without() can't handle an array argument.

Proposed resolution

Option A: Allow a single array argument

@see #11

Change the without() Twig filter to notice if you pass it an array, and if so, use that for its arguments.

This would allow, something like:

{%
  set fancy_fields = [
    'field_certification',
    'field_job_title',
    'field_is_staff',
    'field_is_practitioner',
    'field_is_instructor',
  ]
%}

<article>
  <div class="fancy">
    {% for field in fancy_fields %}
      {{ content[field] }}
    {% endfor %}
  </div>
  <div class="content">
    {{ content|without(fancy_fields) }}
  </div>
</article>

Drupal\Core\Template\TwigExtension::withoutFilter() will currently blow up with a warning if you try to pass an array. So we should be able to change this without breaking any BC.

Option B: Mixed arguments that handle strings or arrays

See patch #34

Allow a mix of arrays and strings. Even more usable than the above, since if you have 2 fancy regions, you can just do:

<article>
  <div class="important">
    {% for field in important_fields %}
      {{ content[field] }}
    {% endfor %}
  </div>
  <div class="fancy">
    {% for field in fancy_fields %}
      {{ content[field] }}
    {% endfor %}
  </div>
  <div class="content">
    {{ content|without(important_fields, fancy_fields, 'field_special_case') }}
  </div>
</article>

Implementations of B to consider:

  • #18: Explicit foreach, one loop, needs inner loop with sort of duplicate code.
        foreach ($args as $arg) {
          if (is_array($arg)) {
            foreach ($arg as $key) {
              if (isset($filtered_element[$key])) {
                unset($filtered_element[$key]);
              }
            }
          }
          elseif (isset($filtered_element[$arg])) {
            unset($filtered_element[$arg]);
          }
        }
    
  • #19: 2 loops: 1 to get all the keys, second loop to unset everything
        $keys = [];
        foreach ($args as $arg) {
          if (is_array($arg)) {
            foreach ($arg as $key) {
              $keys[] = $key;
            }
          }
          else {
            $keys[] = $arg;
          }
        }
        foreach ($keys as $key) {
          if (isset($filtered_element[$key])) {
            unset($filtered_element[$key]);
          }
       }
    
  • #34: RecursiveIterator classes to only iterate once, no duplicate code
        // Since the remaining arguments can be a mix of arrays and strings, we use
        // some native PHP iterator classes to allow us to recursively iterate over
        // everything in a single pass.
        $iterator = new \RecursiveIteratorIterator(new \RecursiveArrayIterator($args));
        foreach ($iterator as $key) {
          if (isset($filtered_element[$key])) {
            unset($filtered_element[$key]);
          }
        }
    

Remaining tasks

  1. Do it.
  2. Add some tests that show it works as expected.
  3. Decide on #11 (single array argument) vs. #18 (allow mix of strings and arrays) -- we're leaning towards B.: Winner: Mix of strings and arrays
  4. Decide if any of the alternate implementations in #19 or #21 are better than #18: Winner: #21 (plus nits fixed in #34)< /li>
  5. Review.
  6. RTBC.
  7. Commit.

User interface changes

-

API changes

Twig templates using the without() filter can optionally use a mix of arrays and strings for the arguments, instead of having to specify them all manually.

Data model changes

-

Release notes snippet

N/a

Support from Acquia helps fund testing for Drupal Acquia logo

Comments

dww created an issue. See original summary.

dww’s picture

Issue summary: View changes
Status: Active » Needs review
Issue tags: +Needs tests
FileSize
572 bytes

Still needs tests, but let's make sure this doesn't break any current tests...

Manual testing locally works great.

dww’s picture

Issue summary: View changes
Issue tags: -Needs tests
FileSize
2.77 KB

Found the existing tests for TwigExtension, so added a trivial case to prove this works. Passes locally. Can't imagine how this breaks anything else. What's the word, bot?

Ghost of Drupal Past’s picture

Status: Needs review » Reviewed & tested by the community
  1. Can't break a thing. Previously you'd get a warning if you'd put an array there and it wouldn't work.
  2. Useful
  3. Tested

So yeah, let's do this.

dww’s picture

Issue summary: View changes

Sweet, thanks!

Updating remaining tasks. ;)

pifagor’s picture

Look good for me

Krzysztof Domański’s picture

The change record will be useful here.

Krzysztof Domański’s picture

Status: Reviewed & tested by the community » Needs review
FileSize
951 bytes
3.26 KB

Updated documentation.

Fabianx’s picture

Status: Needs review » Reviewed & tested by the community
+++ b/core/lib/Drupal/Core/Template/TwigExtension.php
@@ -651,8 +651,8 @@ public function createAttribute(array $attributes = []) {
+   * @param array[]|string[] ...
+   *   An array of string keys or string keys of $element to prevent printing.

This feels wrong, but so does the original comment.

If that was correct, then the new one is correct as well.

---

Besides that setting to RTBC (in my role as theme api maintainer), looks good to me.

dww’s picture

Assigned: Unassigned » dww
Status: Reviewed & tested by the community » Needs work

Thanks for the reviews!

Sadly, the new docs are indeed wrong, or at least, misleading. You can't pass an array of arrays. But probably you should be able to. ;)

+++ b/core/lib/Drupal/Core/Template/TwigExtension.php
@@ -666,6 +666,10 @@ public function withoutFilter($element) {
+    // Allows use a Twig array for the arguments.

Typo.

I'll fix these given the current approach.

dww’s picture

Status: Needs work » Needs review
FileSize
3.35 KB
992 bytes

This fixes the doc problems from #8.

However, now that I'm starting to use this on a real site, I'm seeing a need for either multiple arrays, or a mix of arrays and strings. Otherwise, you have to jump through some hoops with merge() inside your Twig template. We're merging here, anyway. Might as well make this even easier.

Stay tuned.

dww’s picture

Assigned: dww » Unassigned
Issue summary: View changes
FileSize
4.08 KB
3.69 KB

Now this allows mixed[] as the arguments, either arrays of keys, or directly string keys. With updated docs and tests.

Maybe there's a slightly more elegant want to do this, but I didn't want to have to iterate through $args twice to make a single $keys array first, then actually unset() stuff. Nitpicks welcome. ;)

I added this as a separate option B to the summary's proposed solution, showing why allowing mixed is even better in real world scenarios.

Also needs review and RTBC again, so updating remaining tasks... ;)

I'll wait for a CR until we agree on #11 vs. this approach.

Leaving both #11 and #12 patches visible in the file table, since those are the two viable approaches to consider.

Cheers,
-Derek

Ghost of Drupal Past’s picture

new RecursiveIteratorIterator(new RecursiveArrayIterator($args)); to simplify your loop.

Fabianx’s picture

+++ b/core/modules/system/tests/src/Kernel/Theme/TwigFilterTest.php
@@ -107,6 +107,14 @@
+        'message' => 'Attributes printed without a string key then an  array of keys.',

typo - additional space in here ;)

---

RTBC - so that anyone can set RTBC status for #12 if they want to, but not setting that status so that #13 can be considered.

dww’s picture

Issue summary: View changes
FileSize
4.07 KB
724 bytes

Re: #13:

new RecursiveIteratorIterator(new RecursiveArrayIterator($args)); to simplify your loop.

Hah, MUCH simpler. ;) 🤦‍♂️What's the world coming to? ;)

Re: #14 Nice catch. Fixed here.

Status: Needs review » Needs work

The last submitted patch, 15: 3093577-15.patch, failed testing. View results

Krzysztof Domański’s picture

Status: Needs work » Needs review

1. #16 Unrelated random test failure.

2. Can we add an additional test case? Two arrays with a duplicate key.

--- a/core/modules/system/tests/modules/twig_theme_test/templates/twig_theme_test.filter.html.twig
+++ b/core/modules/system/tests/modules/twig_theme_test/templates/twig_theme_test.filter.html.twig
@@ -23,6 +23,7 @@
 <div><span{{ attributes|without(without_args) }}>Without id and class attributes via an array.</span></div>
 <div><span{{ attributes|without(without_args, 'checked') }}>Without any attributes via mixed array and string.</span></div>
 <div><span{{ attributes|without('checked', without_args) }}>Without any attributes via mixed string then array.</span></div>
+<div><span{{ attributes|without(without_args, ['id', 'checked']) }}>Without any attributes with duplicate 'id' key.</span></div>
 <div><span{{ attributes }}>All attributes again.</span></div>
dww’s picture

Issue summary: View changes
FileSize
4.43 KB
2.16 KB

Re: #17.2: Sure, why not? Moar Tests(tm) is always good. ;) Especially since it's inside an existing test class and it's effectively free to test more combinations of this.

dww’s picture

For comparison, 2 alternate approaches...

This does an initial loop to find all the keys.
Then a second loop to unset them.

No changes to test coverage. Passes locally.

dww’s picture

With the "simple" solution from #13. ;)

edit: Note: we need \ in front for PHP to find the right class. These are PHP native classes. Using exactly #13 fails with:

1) Drupal\Tests\system\Kernel\Theme\TwigFilterTest::testTwigWithoutFilter
Error: Class 'Drupal\Core\Template\RecursiveIteratorIterator' not found
dww’s picture

#20 plus a code comment to explain WTF. ;)

dww’s picture

Note: if we go with #18 and up, the issue title should be changed to:

"Let our Twig without() filter take arrays and strings as arguments"

before commit.

Thanks!
-Derek

dww’s picture

Issue summary: View changes

Canceled the test run on #20. If we do that, we need the code comment from #21. ;) So let's save the bot cycles churning on #20...

dww’s picture

Issue summary: View changes

Put the implementation comparison for #18 vs. #19 vs. #21 into the summary for easier review.

Okay, people, let's pick. ;)

Thanks!
-Derek

Krzysztof Domański’s picture

Option B +1

1. Re #21 Let's treat only arrays (not objects) as having children for recursive iteration.

-    $iterator = new \RecursiveIteratorIterator(new \RecursiveArrayIterator($args));
+    $iterator = new \RecursiveIteratorIterator(new \RecursiveArrayIterator($args, \RecursiveArrayIterator::CHILD_ARRAYS_ONLY));

The RecursiveArrayIterator class

This iterator allows to unset and modify values and keys while iterating over Arrays and Objects

2. Can we use The ArrayIterator class instead of The RecursiveArrayIterator class?

$iterator = new \RecursiveIteratorIterator(new \ArrayIterator($args));

Krzysztof Domański’s picture

-- edited --

dww’s picture

Title: Let our Twig without() filter take an array argument » Let our Twig without() filter take both arrays and strings as arguments
Issue summary: View changes

@Krzysztof Domański re: #25

Agreed on option B. I think it'd be weird to require and handle only a single array, and would probably result in more trouble than it helps. Preemptively solving #22 by re-titling this now. If someone really thinks we need option A, we can reset it.

Re: #25.1: wow, the "cure" is turning into something worse than the "disease". ;) So much for simple. :/ How, exactly, would someone be able to pass an object into this inside a twig template? Seems like unnecessary complication.

Re: #25.2: I don't think so. The whole point is that the $args array from func_get_args() might include nested arrays. That's what a RecursiveArrayIterator handles. If we make this just an ArrayIterator, we'd only walk through the keys in $args itself, not sub arrays. I think. ;)

dww’s picture

Re: #25.2 : I just confirmed #27 in local testing:

Doing exactly this:

    $iterator = new \RecursiveIteratorIterator(new \ArrayIterator($args));

Results in this:

1) Drupal\Tests\system\Kernel\Theme\TwigFilterTest::testTwigWithoutFilter
InvalidArgumentException: An instance of RecursiveIterator or IteratorAggregate creating it is required

Doing this:

    $iterator = new \ArrayIterator($args);

Results in this:

There was 1 error:

1) Drupal\Tests\system\Kernel\Theme\TwigFilterTest::testTwigWithoutFilter
Illegal offset type in isset or empty

/Applications/MAMP-common/htdocs/drupal-8_8/core/lib/Drupal/Core/Template/Attribute.php:159
/Applications/MAMP-common/htdocs/drupal-8_8/core/lib/Drupal/Core/Template/TwigExtension.php:675

Line 675 would be this:

      if (isset($filtered_element[$key])) {

So yeah, as expected, we need \RecursiveArrayIterator to get any benefit from #21 over #18.

Cheers,
-Derek

Krzysztof Domański’s picture

Version: 8.8.x-dev » 8.9.x-dev
Status: Needs review » Reviewed & tested by the community

+1 for #21.

lauriii’s picture

Title: Let our Twig without() filter take both arrays and strings as arguments » Let Twig without() filter take both arrays and strings as arguments
Status: Reviewed & tested by the community » Needs review
Issue tags: +Needs change record

Could someone write a change record for the API change? 🙏

dww’s picture

Issue tags: -Needs change record

Sure. If we're cool with mix of strings and multiple arrays (#21) here we go:

https://www.drupal.org/node/3102853

I assumed this won't be backported to 8.8.x, so I put it as introduced in 8.9.x branch and 8.9.0-alpha1 release. Please adjust accordingly when committing this and publishing that.

Thanks,
-Derek

dww’s picture

Status: Needs review » Reviewed & tested by the community

I guess it's safe to restore the status, too, since the only objection to RTBC was the lack of a CR...

lauriii’s picture

Status: Reviewed & tested by the community » Needs work

Thank you for opening the change record!

  1. +++ b/core/lib/Drupal/Core/Template/TwigExtension.php
    @@ -651,8 +651,9 @@ public function createAttribute(array $attributes = []) {
    +   * @param mixed[] ...
    

    How about documenting this as string[]|string ...? 🧐

  2. +++ b/core/lib/Drupal/Core/Template/TwigExtension.php
    @@ -666,9 +667,13 @@ public function withoutFilter($element) {
    +      if (isset($filtered_element[$key])) {
    +        unset($filtered_element[$key]);
    

    The isset is redundant because unset doesn't give an error for unsetting non-existing array items.

dww’s picture

Re: #33.1: If you prefer, sure. I still think mixed[] is more true, but I don't really feel that strongly.

Re: #33.2. Okay, fixed.

Thanks,
-Derek

lauriii’s picture

I still think mixed[] is more true, but I don't really feel that strongly.

I'm wondering if I'm missing something here since I felt mixed[] was inaccurate. Wouldn't mixed[] essentially translate into arrays of mixed type of items? AFAIK what we accept on this parameter is either array of strings or a string.

dww’s picture

@lauriii re: #35: I've never found a good reference on The Right Way(tm) to document ... in general, that's all. :) You're totally right that's what each element of ... can be: a string, or an array of strings. But technically the "..." is itself an array, and its children elements are of mixed type (strings or arrays). That's my confusion. But if we ignore the array, #33.1 is indeed better. I'll plan to do that from now on whenever documenting ... args.

Can we call #34 RTBC?

Thanks!
-Derek

lauriii’s picture

Status: Needs review » Reviewed & tested by the community

I think #34 is ready for RTBC! Thank you for addressing the feedback @dww!

Percy101’s picture

The patch applied cleanly

alexpott’s picture

Status: Reviewed & tested by the community » Fixed

Committed and pushed 12dcba2532 to 9.0.x and d6ee61eaeb to 8.9.x. Thanks!

alexpott’s picture

Issue summary: View changes

I don't think this is a notable enough change to announce on the release notes - themers don't have to make any changes to existing code and the CR covers the new functionality.

  • alexpott committed 12dcba2 on 9.0.x
    Issue #3093577 by dww, Krzysztof Domański, lauriii, Charlie ChX Negyesi...

  • alexpott committed d6ee61e on 8.9.x
    Issue #3093577 by dww, Krzysztof Domański, lauriii, Charlie ChX Negyesi...

Status: Fixed » Closed (fixed)

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

losewn’s picture

Example: in variable 'content' was 10 elements (name, date, id,...)
{{ content }} - get all fields
{{ content|without('date') }} get all fields(name, (((deleted - date))), id,...), except 'date'