Problem/Motivation

When a user is not allowed to access any contextual links, we still render a contextual dropdown, but without any links in it. This is very confusing, bad UX.

Proposed resolution

Root cause analysis:

  1. User starts with a fresh browser tab. User loads page.
  2. We (== contextual.js) check the cache, we do:
    var html = storage.getItem('Drupal.contextual.' + contextualID);
    if (html !== null) {
      // cache hit, yay!
    

    If there's a cache miss, then getItem() returns null.

  3. We had a cache miss, so we must request it. We iterate over the results in the response, we do this for every requested contextual link placeholder: if (html.length > 0) { — and only if some HTML is returned for a particular contextual link (i.e. if the current user has access to any of the contextual links), we call initContextual(). This is the intent.
  4. User navigates to another page.
  5. We again check the cache:
    var html = storage.getItem('Drupal.contextual.' + contextualID);
    if (html !== null) {
      // cache hit, yay!
    

    Now we have a cache hit! So we call initContextual(). But… we call it blindly. We forget to check if the current user has access.

(Look at \Drupal\contextual\Tests\ContextualDynamicContextTest::testDifferentPermissions(), you'll see that an empty string being returned for a given contextual link ID is indeed intended to return the empty string when it is inaccessible.)

Conclusion: when using the client-side cache, we must not only check html !== null, but also html !== '' (or html.length > 0.

Remaining tasks

Test coverage!

User interface changes

None, except a no longer broken UI.

API changes

None.

Data model changes

None.

CommentFileSizeAuthor
#72 interdiff.txt589 bytesGrandmaGlassesRopeMan
#72 2650910-72.patch2.83 KBGrandmaGlassesRopeMan
#70 interdiff-2650910-64-70.txt1.53 KBmarkdorison
#70 contextual_links_button-2650910-70.patch2.84 KBmarkdorison
#64 interdiff.txt1.03 KBGrandmaGlassesRopeMan
#64 2650910-64.patch3.39 KBGrandmaGlassesRopeMan
#60 interdiff.txt683 bytesGrandmaGlassesRopeMan
#60 2650910-59.patch3.27 KBGrandmaGlassesRopeMan
#60 2650910-59-TEST_ONLY.patch1.99 KBGrandmaGlassesRopeMan
#57 interdiff.txt926 bytesGrandmaGlassesRopeMan
#57 2650910-57.patch3.24 KBGrandmaGlassesRopeMan
#56 interdiff.txt1.08 KBGrandmaGlassesRopeMan
#56 2650910-56.patch3.11 KBGrandmaGlassesRopeMan
#54 interdiff.txt892 bytesGrandmaGlassesRopeMan
#54 2650910-54.patch3.21 KBGrandmaGlassesRopeMan
#52 interdiff.txt1.75 KBGrandmaGlassesRopeMan
#52 2650910-51.patch3.12 KBGrandmaGlassesRopeMan
#42 interdiff.txt1.27 KBGrandmaGlassesRopeMan
#42 2650910-42.patch2.61 KBGrandmaGlassesRopeMan
#35 interdiff.txt1.14 KBGrandmaGlassesRopeMan
#35 2650910-35.patch2.11 KBGrandmaGlassesRopeMan
#35 2650910-35-tests.patch1.33 KBGrandmaGlassesRopeMan
#32 drupal-contextual_links-2650910-29-patched.png1.54 MBrachel_norfolk
#32 drupal-contextual_links-2650910-29-unpatched.png1.54 MBrachel_norfolk
#29 drupal-contextual_links-2650910-29.patch1.27 KByannickoo
#18 8.2.x-dev-2650910-18.patch779 bytespsend
#12 8.2.x-dev-2650910-12.patch528 bytesanishnirmal
#12 After Patch - auth user.png65.37 KBanishnirmal
#12 Before Patch - auth user.png62.96 KBanishnirmal
#8 user_with_cont_links_permission_after_patch.png94.26 KBanishnirmal
#8 user_with_cont_links_permission_before_patch.png94.68 KBanishnirmal
#8 admin_with_cont_links.png112.15 KBanishnirmal
#4 drupal_core_contextual-empty_contextual_filters-2650910-8.1.x-dev.patch531 bytestom robert

Comments

Tom Robert created an issue. See original summary.

tom robert’s picture

Issue summary: View changes
tom robert’s picture

Issue summary: View changes
tom robert’s picture

tom robert’s picture

Status: Active » Needs review
anishnirmal’s picture

Status: Needs review » Reviewed & tested by the community
Issue tags: +SprintWeekend2016Chennai

The patch drupal_core_contextual-empty_contextual_filters-2650910-8.1.x-dev.patch at #4 works.

alexpott’s picture

Status: Reviewed & tested by the community » Needs work
Issue tags: +Needs screenshots

The patch looks good but as this is a javascript change and we have no test infrastructure in place we need some screenshots as test evidence.

anishnirmal’s picture

Hi, Please refer the below screenshots

Admin user with the contextual links
admin user - cont links

Authenticated user with Contextual link Permission enabled - Before patch
The Link icon exist without sub-links
before patch

Authenticated user with Contextual link Permission enabled - After patch
The Link icon not exist without sub-links
After patch

anishnirmal’s picture

Status: Needs work » Needs review

Version: 8.1.x-dev » 8.2.x-dev

Drupal 8.1.0-beta1 was released on March 2, 2016, which means new developments and disruptive changes should now be targeted against the 8.2.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

dbjpanda’s picture

Issue tags: -Needs screenshots
anishnirmal’s picture

Issue summary: View changes
StatusFileSize
new62.96 KB
new65.37 KB
new528 bytes

Hi,
Created the patch for D8.2.x-dev version. Below i have attached the before and after screenshots and it works fine

Before Patch:
before patch

After Patch:
after patch

wim leers’s picture

Status: Needs review » Needs work
+++ b/core/modules/contextual/js/contextual.js
@@ -42,6 +42,9 @@
+    if ($(html).find('a').length === 0) {
+      return;
+    }

This is wrong. This won't work if the content contains any link, including non-contextual links

tom robert’s picture

Status: Needs work » Needs review

The html element contains only the html for the contextual links

 /**
   * Initializes a contextual link: updates its DOM, sets up model and views.
   *
   * @param {jQuery} $contextual
   *   A contextual links placeholder DOM element, containing the actual
   *   contextual links as rendered by the server.
   * @param {string} html
   *   The server-side rendered HTML for this contextual link.
   */
  function initContextual($contextual, html) {
    if ($(html).find('a').length === 0) {
      return;
    }
...

So in the html there is only something like this:

<ul>
  <li><a href="x">X</a></li>
  ...
</ul>

So if html doesn't contain a link don't render the links.

wim leers’s picture

Status: Needs review » Needs work
Issue tags: +Needs tests, +JavaScript

You're right. Sorry. I misread the code (it's been a long time since I've seen this!).

But… your code is still misleading. The right thing to do is checking if html is empty or not. Because if no links are accessible, then html will be the empty string. Verify it in your debugger.

Also, #2469713: Step 2: Create a JavaScriptTestBase using PhantomJs Driver/Binary has landed, so now we can write test coverage for cases like this! Look at core/modules/toolbar/tests/src/FunctionalJavascript/ToolbarIntegrationTest for an example.

tom robert’s picture

That is indeed correct. I thought that when i tested it i received an empty <ul>. But this is not the case anymore so an empty check should suffice.

wim leers’s picture

Cool :) Thanks! And thanks for helping to fix this!

psend’s picture

If the user do not have permission to access the associated links then contextual links are empty.
It is better to check if contextual links are not empty after we get them from the sessionStorage.

var html = storage.getItem('Drupal.contextual.' + contextualID);
        if (html !== null && html.length > 0) {
          // Initialize after the current execution cycle, to make the AJAX
          // request for retrieving the uncached contextual links as soon as
          // possible, but also to ensure that other Drupal behaviors have had
          // the chance to set up an event listener on the Backbone collection
          // Drupal.contextual.collection.
          window.setTimeout(function () {
            initContextual($context.find('[data-contextual-id="' + contextualID + '"]'), html);
          });
          return false;
        }

As it is checked after the Ajax request:

$.ajax({
          url: Drupal.url('contextual/render'),
          type: 'POST',
          data: {'ids[]': uncachedIDs},
          dataType: 'json',
          success: function (results) {
            _.each(results, function (html, contextualID) {
              // Store the metadata.
              storage.setItem('Drupal.contextual.' + contextualID, html);
              // If the rendered contextual links are empty, then the current
              // user does not have permission to access the associated links:
              // don't render anything.
             if (html.length > 0) {
                ...
                for (var i = 0; i < $placeholders.length; i++) {
                  initContextual($placeholders.eq(i), html);
                }
              }
            });
          }
        });
psend’s picture

Status: Needs work » Needs review
dpi’s picture

Adding related issue since this issue does not have good Google juice.

markdorison’s picture

Status: Needs review » Reviewed & tested by the community

Patch in #18 works as expected.

droplet’s picture

Status: Reviewed & tested by the community » Needs review

the code that handling `storage.setItem` & `storage.getItem` should be same logic. Unbalance coding confused developers. Also considering the shortened way to check value exist: `if(var) { ...}`.

wim leers’s picture

The patch in #18 is exactly right.

  1. User starts with a fresh browser tab. User loads page.
  2. We (== contextual.js) check the cache, we do:
    var html = storage.getItem('Drupal.contextual.' + contextualID);
    if (html !== null) {
      // cache hit, yay!
    

    If there's a cache miss, then getItem() returns null.

  3. We had a cache miss, so we must request it. We iterate over the results in the response, we do this for every requested contextual link placeholder: if (html.length > 0) { — and only if some HTML is returned for a particular contextual link (i.e. if the current user has access to any of the contextual links), we call initContextual(). This is the intent.
  4. User navigates to another page.
  5. We again check the cache:
    var html = storage.getItem('Drupal.contextual.' + contextualID);
    if (html !== null) {
      // cache hit, yay!
    

    Now we have a cache hit! So we call initContextual(). But… we call it blindly. We forget to check if the current user has access.

(Look at \Drupal\contextual\Tests\ContextualDynamicContextTest::testDifferentPermissions(), you'll see that an empty string being returned for a given contextual link ID is indeed intended to return the empty string when it is inaccessible.)

Conclusion: the patch in #18 is entirely correct.


So -1 for #22.


However, this still needs JS test coverage.

wim leers’s picture

Issue summary: View changes

Actually, even better would be to test html !== '', because html.length > 0 would e.g. also allow arrays/objects.

Updated IS with #23's analysis.

xjm’s picture

Version: 8.2.x-dev » 8.3.x-dev

Drupal 8.2.0-beta1 was released on August 3, 2016, which means new developments and disruptive changes should now be targeted against the 8.3.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

wim leers’s picture

Title: Contextual links button is always rendered even when no links are available » Contextual links button is always rendered even when no links are available (with warm client-side cache)
Related issues:

#2047671: Expanded contextual links look goofy when there are no items fixed this for the non-cached case. This is fixing it for the cached case.

yannickoo’s picture

What do you think about this condition like this: typeof html === 'string' && html.length > 0?

yannickoo’s picture

StatusFileSize
new1.27 KB

(And one question: Does the core prefer foo.length > 0 instead of just foo.length for readability reasons?)

flodevelop’s picture

Thank you for the patch, it works for me.

othermachines’s picture

Patch in #29 fixed the problem of the contextual button displaying when no contextual links were available (Drupal 8.2.3). Thanks!

rachel_norfolk’s picture

Status: Needs review » Reviewed & tested by the community
StatusFileSize
new1.54 MB
new1.54 MB

This looks *really* simple solution and the attached unpatched and patched screenshots show it is effective. Marking RTBC...

Version: 8.3.x-dev » 8.4.x-dev

Drupal 8.3.0-alpha1 will be released the week of January 30, 2017, which means new developments and disruptive changes should now be targeted against the 8.4.x-dev branch. For more information see the Drupal 8 minor version schedule and the Allowed changes during the Drupal 8 release cycle.

xjm’s picture

Status: Reviewed & tested by the community » Needs work

As per #23 this still needs test coverage. Thanks everyone!

GrandmaGlassesRopeMan’s picture

Status: Needs work » Needs review
Issue tags: -Needs tests
StatusFileSize
new1.33 KB
new2.11 KB
new1.14 KB

Tests.

The last submitted patch, 35: 2650910-35-tests.patch, failed testing.

wim leers’s picture

Status: Needs review » Reviewed & tested by the community

YAY!

rachel_norfolk’s picture

Version: 8.4.x-dev » 8.3.x-dev

This is a bug and the "benefits outweigh the disruption". We are still in 8.3 beta so I think this can be applied to 8.3

I was going to say I was too timid to change it but that's something I'm consciously trying to change!

Please correct if I'm wrong.

wim leers’s picture

Nope, you're absolutely right. And even if 8.3.0 was already released, this would still be able to go in to 8.3, because it's simply a bugfix without other consequences.

droplet’s picture

Status: Reviewed & tested by the community » Needs review

I disagreed with #23, +1 for #22

First,
It patched the LINE 166 with:

if (typeof html === 'string' && html.length > 0) {

But LINE 195:

if (html.length > 0) {

Unless we make assumptions there, I don't know why we end up with 2 different conditions.

Second,

stringNull = null;

if(stringNull) {
 console.log('stringNull error')
}

stringEmpty = '';

if(stringEmpty) {
 console.log('stringEmpty error')
}

stringHTML = 'CORRECT';

if(stringHTML) {
 console.log('stringHTML correct')
}

Thrid:

window.sessionStorage.setItem('arrayTest', ['arrayItem']);

var isArray = window.sessionStorage.getItem('arrayTest');

console.log(`typeof isArray is ${typeof isArray}`)

Storage interface returns DOMString, or null.

droplet’s picture

also,

LINE 191:

              storage.setItem('Drupal.contextual.' + contextualID, html);

Should we save empty HTML then?

GrandmaGlassesRopeMan’s picture

StatusFileSize
new2.61 KB
new1.27 KB

@droplet

Thanks for pointing that out. I updated the logic to check for a non-null element and that the element has a length.

wim leers’s picture

#41: yes, saving the empty HTML is intentional: that means the current user cannot access those particular contextual links. It's pointless to re-request them over and over again. If you want to change that, then let's do that in a separate issue.


#42: See #24 for why I suggested checking if it's a string. It was typeof html === 'string' until #42, but I think html !== '' would have been okay too.
That's no longer being checked in #42. Are you sure that this is okay?

GrandmaGlassesRopeMan’s picture

@Wim Leers

It's my understanding that the interface for window.sessionStorage is to always return a string when requesting an item. Here we are checking to see that html exists, not null, and that it has a length; which in weird JavaScript land, the result of checking the length of the string will result in a true case if that value is non-zero.

wim leers’s picture

+++ b/core/modules/contextual/js/contextual.js
@@ -192,7 +192,7 @@
-              if (html.length > 0) {
+              if (html && html.length) {

Yes, but in this location, html is not read from sessionStorage. So it might be an object or an array — which is what I wrote in #24.

GrandmaGlassesRopeMan’s picture

@Wim Leers

If there’s ever a possibility that the result of the AJAX call to contextual/render will return something that is not a string then we need to expand the checking.

wim leers’s picture

Status: Needs review » Reviewed & tested by the community

I don't see how that can happen, so then this is fine. Still, by that same logic, we shouldn't even be modifying line 196. Because html cannot ever be null when parsing a successful AJAX response. (In other words: by that same logic, #23's argument is invalid.)

Anyway, this is fine. Let's get this in. Finally.

droplet’s picture

Still, by that same logic, we shouldn't even be modifying line 196. Because html cannot ever be null when parsing a successful AJAX response.

Yes, it is. (or a chance returned false or null?) Basically, I suggested if (html) {} but if we end up with current patch. It's still fine.

alexpott’s picture

Status: Reviewed & tested by the community » Needs work
+++ b/core/modules/contextual/tests/src/FunctionalJavascript/ContextualLinksTest.php
@@ -0,0 +1,42 @@
+    $this->assertSession()->elementNotExists('css', '.contextual button');
...
+    $this->assertSession()->elementNotExists('css', '.contextual button');

This test is only a negative test - is there any chance to add additional tests to ensure contextual links are there in the right conditions?

imiksu’s picture

Issue tags: +DevDays Seville
duaelfr’s picture

Issue tags: -DevDays Seville +DevDaysSeville

Fixing event tag :)

GrandmaGlassesRopeMan’s picture

Status: Needs work » Needs review
StatusFileSize
new3.12 KB
new1.75 KB

Adding an additional test step from #49.

Status: Needs review » Needs work

The last submitted patch, 52: 2650910-51.patch, failed testing.

GrandmaGlassesRopeMan’s picture

Status: Needs work » Needs review
StatusFileSize
new3.21 KB
new892 bytes

Need to wait for ajax to complete.

Status: Needs review » Needs work

The last submitted patch, 54: 2650910-54.patch, failed testing.

GrandmaGlassesRopeMan’s picture

Status: Needs work » Needs review
StatusFileSize
new3.11 KB
new1.08 KB
  • After some coversation at DevDays, I think waitForElement makes more sense.
GrandmaGlassesRopeMan’s picture

StatusFileSize
new3.24 KB
new926 bytes
wim leers’s picture

  1. +++ b/core/modules/contextual/tests/src/FunctionalJavascript/ContextualLinksTest.php
    @@ -0,0 +1,60 @@
    +    // Grant Permissions
    

    s/Grant Permissions/Grant permissions to use contextual links on blocks./

  2. Please upload a test-only patch (which should fail) along with the patch that includes the solution (which should pass).
rachel_norfolk’s picture

Status: Needs review » Needs work

Just updating status to match comments...

GrandmaGlassesRopeMan’s picture

Status: Needs work » Needs review
StatusFileSize
new1.99 KB
new3.27 KB
new683 bytes

@Wim Leers

Thanks.

The last submitted patch, 60: 2650910-59-TEST_ONLY.patch, failed testing.

tedbow’s picture

+++ b/core/modules/contextual/tests/src/FunctionalJavascript/ContextualLinksTest.php
@@ -0,0 +1,60 @@
+    $this->assertSession()->elementNotExists('css', '.contextual button');
...
+    $contextualLinks = $this->assertSession()->waitForElement('css', '.contextual button');
+    $this->assertNotEmpty($contextualLinks);

I think it would better if you used the exact testing before and after you grant the permission.

Instead of using $this->assertSession()->elementNotExists
You can use $this->assertSession()->waitForElement in the first part of the test also. waitForElement() will return NULL if not found after the wait. So you can just check that it is empty.

That way we can be sure that element just doesn't exist because we have waited for it.

tedbow’s picture

Status: Needs review » Needs work
GrandmaGlassesRopeMan’s picture

Status: Needs work » Needs review
StatusFileSize
new3.39 KB
new1.03 KB

Fixes from #62.

dbjpanda’s picture

Status: Needs review » Reviewed & tested by the community

Patch looks good. I tested it and working for me. Changing status to RTBC

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 64: 2650910-64.patch, failed testing.

GrandmaGlassesRopeMan’s picture

Status: Needs work » Needs review

Random test failure.

dbjpanda’s picture

Status: Needs review » Reviewed & tested by the community

I tested the patch and it works for me.

lauriii’s picture

Status: Reviewed & tested by the community » Needs work
  1. +++ b/core/modules/contextual/js/contextual.js
    @@ -192,7 +192,7 @@
    -              if (html.length > 0) {
    +              if (html && html.length) {
    

    As mentioned on #47, this is out of scope change since successfull HTML response can never be null.

  2. +++ b/core/modules/contextual/tests/src/FunctionalJavascript/ContextualLinksTest.php
    @@ -0,0 +1,60 @@
    +use Drupal\user\Entity\Role;
    

    Unused use statement.

  3. +++ b/core/modules/contextual/tests/src/FunctionalJavascript/ContextualLinksTest.php
    @@ -0,0 +1,60 @@
    +}
    

    The closing brace for the class must have an empty line before it.

markdorison’s picture

Status: Needs work » Needs review
StatusFileSize
new2.84 KB
new1.53 KB

Incorporated feedback from #69.

Status: Needs review » Needs work

The last submitted patch, 70: contextual_links_button-2650910-70.patch, failed testing.

GrandmaGlassesRopeMan’s picture

Status: Needs work » Needs review
Issue tags: -DevDaysSeville
StatusFileSize
new2.83 KB
new589 bytes
markdorison’s picture

Status: Needs review » Reviewed & tested by the community
alexpott’s picture

Status: Reviewed & tested by the community » Patch (to be ported)

Committed 488a6b1 and pushed to 8.4.x. Thanks!

I think we should backport this to 8.3.x - going to get a +1 from another committer because we're in RC for 8.3.0.

  • alexpott committed 488a6b1 on 8.4.x
    Issue #2650910 by drpal, Anishnirmal, markdorison, Tom Robert, psend,...
cilefen’s picture

+1—it's a one-line clean change with test coverage to rid us of an ugly problem.

  • alexpott committed 600e6db on 8.3.x
    Issue #2650910 by drpal, Anishnirmal, markdorison, Tom Robert, psend,...
alexpott’s picture

Status: Patch (to be ported) » Fixed

Committed 600e6db and pushed to 8.3.x. Thanks!

Status: Fixed » Closed (fixed)

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