core/modules/tracker/js/tracker-history.js | 122 ++++++++++++++++++++++ core/modules/tracker/src/Tests/TrackerTest.php | 136 +++++++++++-------------- core/modules/tracker/tracker.libraries.yml | 8 ++ core/modules/tracker/tracker.pages.inc | 32 +++--- 4 files changed, 205 insertions(+), 93 deletions(-) diff --git a/core/modules/tracker/js/tracker-history.js b/core/modules/tracker/js/tracker-history.js new file mode 100644 index 0000000..fa6af55 --- /dev/null +++ b/core/modules/tracker/js/tracker-history.js @@ -0,0 +1,122 @@ +/** + * Attaches behaviors for the Tracker module's History module integration. + * + * May only be loaded for authenticated users, with the History module enabled. + */ +(function ($, Drupal, window) { + + "use strict"; + + /** + * Render "new" and "updated" node indicators, as well as "X new" replies links. + */ + Drupal.behaviors.trackerHistory = { + attach: function (context) { + // Find all "new" comment indicator placeholders newer than 30 days ago that + // have not already been read after their last comment timestamp. + var nodeIDs = []; + var $nodeNewPlaceholders = $(context) + .find('[data-history-node-timestamp]') + .once('history') + .filter(function () { + var nodeTimestamp = parseInt(this.getAttribute('data-history-node-timestamp'), 10); + var nodeID = this.getAttribute('data-history-node-id'); + if (Drupal.history.needsServerCheck(nodeID, nodeTimestamp)) { + nodeIDs.push(nodeID); + return true; + } + else { + return false; + } + }); + + // Find all "new" comment indicator placeholders newer than 30 days ago that + // have not already been read after their last comment timestamp. + var $newRepliesPlaceholders = $(context) + .find('[data-history-node-last-comment-timestamp]') + .once('history') + .filter(function () { + var lastCommentTimestamp = parseInt(this.getAttribute('data-history-node-last-comment-timestamp'), 10); + var nodeTimestamp = parseInt(this.previousSibling.previousSibling.getAttribute('data-history-node-timestamp'), 10); + // Discard placeholders that have zero comments. + if (lastCommentTimestamp === nodeTimestamp) { + return false; + } + var nodeID = this.previousSibling.previousSibling.getAttribute('data-history-node-id'); + if (Drupal.history.needsServerCheck(nodeID, lastCommentTimestamp)) { + if (nodeIDs.indexOf(nodeID) === -1) { + nodeIDs.push(nodeID); + } + return true; + } + else { + return false; + } + }); + + if ($nodeNewPlaceholders.length === 0 && $newRepliesPlaceholders.length === 0) { + return; + } + + // Fetch the node read timestamps from the server. + Drupal.history.fetchTimestamps(nodeIDs, function () { + processNodeNewIndicators($nodeNewPlaceholders); + processNewRepliesIndicators($newRepliesPlaceholders); + }); + } + }; + + function processNodeNewIndicators($placeholders) { + var newNodeString = Drupal.t('new'); + var updatedNodeString = Drupal.t('updated'); + + $placeholders.each(function (index, placeholder) { + var timestamp = parseInt(placeholder.getAttribute('data-history-node-timestamp'), 10); + var nodeID = placeholder.getAttribute('data-history-node-id'); + var lastViewTimestamp = Drupal.history.getLastRead(nodeID); + + if (timestamp > lastViewTimestamp) { + var message = (lastViewTimestamp === 0) ? newNodeString : updatedNodeString; + $(placeholder).append('' + message + ''); + } + }); + } + + function processNewRepliesIndicators($placeholders) { + // Figure out which placeholders need the "x new" replies links. + var placeholdersToUpdate = {}; + $placeholders.each(function (index, placeholder) { + var timestamp = parseInt(placeholder.getAttribute('data-history-node-last-comment-timestamp'), 10); + var nodeID = placeholder.previousSibling.previousSibling.getAttribute('data-history-node-id'); + var lastViewTimestamp = Drupal.history.getLastRead(nodeID); + + // Queue this placeholder's "X new" replies link to be downloaded from the + // server. + if (timestamp > lastViewTimestamp) { + placeholdersToUpdate[nodeID] = placeholder; + } + }); + + // Perform an AJAX request to retrieve node view timestamps. + var nodeIDs = Object.keys(placeholdersToUpdate); + if (nodeIDs.length === 0) { + return; + } + $.ajax({ + url: Drupal.url('comments/render_new_comments_node_links'), + type: 'POST', + data: {'node_ids[]': nodeIDs}, + dataType: 'json', + success: function (results) { + for (var nodeID in results) { + if (results.hasOwnProperty(nodeID) && placeholdersToUpdate.hasOwnProperty(nodeID)) { + var url = results[nodeID].first_new_comment_link; + var text = Drupal.formatPlural(results[nodeID].new_comment_count, '1 new', '@count new'); + $(placeholdersToUpdate[nodeID]).append('
' + text + ''); + } + } + } + }); + } + +})(jQuery, Drupal, window); diff --git a/core/modules/tracker/src/Tests/TrackerTest.php b/core/modules/tracker/src/Tests/TrackerTest.php index 0438495..72d691c 100644 --- a/core/modules/tracker/src/Tests/TrackerTest.php +++ b/core/modules/tracker/src/Tests/TrackerTest.php @@ -10,7 +10,9 @@ use Drupal\comment\CommentInterface; use Drupal\comment\Tests\CommentTestTrait; use Drupal\Core\Cache\Cache; +use Drupal\Core\Session\AccountInterface; use Drupal\field\Entity\FieldStorageConfig; +use Drupal\node\Entity\Node; use Drupal\simpletest\WebTestBase; use Drupal\system\Tests\Cache\AssertPageCacheContextsAndTagsTrait; @@ -54,6 +56,10 @@ protected function setUp() { $this->user = $this->drupalCreateUser($permissions); $this->otherUser = $this->drupalCreateUser($permissions); $this->addDefaultCommentField('node', 'page'); + user_role_grant_permissions(AccountInterface::ANONYMOUS_ROLE, array( + 'access content', + 'access user profiles', + )); } /** @@ -168,97 +174,52 @@ function testTrackerUser() { } /** - * Tests for the presence of the "new" flag for nodes. + * Tests the metadata for the "new"/"updated" indicators. */ - function testTrackerNewNodes() { + function testTrackerHistoryMetadata() { $this->drupalLogin($this->user); + // Create a page node. $edit = array( 'title' => $this->randomMachineName(8), ); - $node = $this->drupalCreateNode($edit); - $title = $edit['title']; - $this->drupalGet('activity'); - $this->assertPattern('/' . $title . '.*new/', 'New nodes are flagged as such in the activity listing.'); - - $this->drupalGet('node/' . $node->id()); - // Simulate the JavaScript on the node page to mark the node as read. - // @todo Get rid of curlExec() once https://www.drupal.org/node/2074037 - // lands. - $this->curlExec(array( - CURLOPT_URL => \Drupal::url('history.read_node', ['node' => $node->id()], array('absolute' => TRUE)), - CURLOPT_HTTPHEADER => array( - 'Accept: application/json', - ), - )); - $this->drupalGet('activity'); - $this->assertNoPattern('/' . $title . '.*new/', 'Visited nodes are not flagged as new.'); - $this->drupalLogin($this->otherUser); + // Verify. $this->drupalGet('activity'); - $this->assertPattern('/' . $title . '.*new/', 'For another user, new nodes are flagged as such in the tracker listing.'); - - $this->drupalGet('node/' . $node->id()); - // Simulate the JavaScript on the node page to mark the node as read. - // @todo Get rid of curlExec() once https://www.drupal.org/node/2074037 - // lands. - $this->curlExec(array( - CURLOPT_URL => \Drupal::url('history.read_node', ['node' => $node->id()], array('absolute' => TRUE)), - CURLOPT_HTTPHEADER => array( - 'Accept: application/json', - ), - )); - $this->drupalGet('activity'); - $this->assertNoPattern('/' . $title . '.*new/', 'For another user, visited nodes are not flagged as new.'); - } - - /** - * Tests for comment counters on the tracker listing. - */ - function testTrackerNewComments() { - $this->drupalLogin($this->user); - - $node = $this->drupalCreateNode(array( - 'title' => $this->randomMachineName(8), - )); + $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->getChangedTime()); + $this->drupalGet('activity/' . $this->user->id()); + $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->getChangedTime()); + $this->drupalGet('user/' . $this->user->id() . '/activity'); + $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->getChangedTime()); - // Add a comment to the page. + // Add a comment to the page, make sure it is created after the node. $comment = array( 'subject[0][value]' => $this->randomMachineName(), 'comment_body[0][value]' => $this->randomMachineName(20), ); + sleep(1); $this->drupalPostForm('comment/reply/node/' . $node->id() . '/comment', $comment, t('Save')); - // The new comment is automatically viewed by the current user. Simulate the - // JavaScript that does this. - // @todo Get rid of curlExec() once https://www.drupal.org/node/2074037 - // lands. - $this->curlExec(array( - CURLOPT_URL => \Drupal::url('history.read_node', ['node' => $node->id()], array('absolute' => TRUE)), - CURLOPT_HTTPHEADER => array( - 'Accept: application/json', - ), - )); + // Reload the node so that comment.module's hook_node_load() + // implementation can set $node->last_comment_timestamp for the freshly + // posted comment. + $node = Node::load($node->id()); - $this->drupalLogin($this->otherUser); + // Verify. $this->drupalGet('activity'); - $this->assertText('1 new', 'New comments are counted on the tracker listing pages.'); - $this->drupalGet('node/' . $node->id()); - - // Add another comment as otherUser. - $comment = array( - 'subject[0][value]' => $this->randomMachineName(), - 'comment_body[0][value]' => $this->randomMachineName(20), - ); - // If the comment is posted in the same second as the last one then Drupal - // can't tell the difference, so we wait one second here. - sleep(1); - $this->drupalPostForm('comment/reply/node/' . $node->id(). '/comment', $comment, t('Save')); + $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->get('comment')->last_comment_timestamp); + $this->drupalGet('activity/' . $this->user->id()); + $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->get('comment')->last_comment_timestamp); + $this->drupalGet('user/' . $this->user->id() . '/activity'); + $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->get('comment')->last_comment_timestamp); - $this->drupalLogin($this->user); + // Log out, now verify that the metadata is still there, but the library is + // not. + $this->drupalLogout(); $this->drupalGet('activity'); - $this->assertText('1 new', 'New comments are counted on the tracker listing pages.'); - $this->assertLink(t('1 new')); + $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->get('comment')->last_comment_timestamp, FALSE); + $this->drupalGet('user/' . $this->user->id() . '/activity'); + $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->get('comment')->last_comment_timestamp, FALSE); } /** @@ -370,8 +331,6 @@ function testTrackerCronIndexing() { foreach ($nodes as $i => $node) { $this->assertText($node->label(), format_string('Node @i is displayed on the tracker listing pages.', array('@i' => $i))); } - $this->assertText('1 new', 'One new comment is counted on the tracker listing pages.'); - $this->assertText('updated', 'Node is listed as updated'); // Fetch the site-wide tracker. $this->drupalGet('activity'); @@ -380,7 +339,6 @@ function testTrackerCronIndexing() { foreach ($nodes as $i => $node) { $this->assertText($node->label(), format_string('Node @i is displayed on the tracker listing pages.', array('@i' => $i))); } - $this->assertText('1 new', 'New comment is counted on the tracker listing pages.'); } /** @@ -410,4 +368,32 @@ function testTrackerAdminUnpublish() { $this->drupalGet('activity'); $this->assertText(t('No content available.'), 'A node is displayed on the tracker listing pages.'); } + + /** + * Passes if the appropriate history metadata exists. + * + * Verify the data-history-node-id, data-history-node-timestamp and + * data-history-node-last-comment-timestamp attributes, which are used by the + * drupal.tracker-history library to add the appropriate "new" and "updated" + * indicators, as well as the "x new" replies link to the tracker. + * We do this in JavaScript to prevent breaking the render cache. + * + * @param $node_id + * A node ID, that must exist as a data-history-node-id attribute + * @param $node_timestamp + * A node timestamp, that must exist as a data-history-node-timestamp + * attribute. + * @param $node_last_comment_timestamp + * A node's last comment timestamp, that must exist as a + * data-history-node-last-comment-timestamp attribute. + * @param bool $library_is_present + * Whether the drupal.tracker-history library should be present or not. + */ + function assertHistoryMetadata($node_id, $node_timestamp, $node_last_comment_timestamp, $library_is_present = TRUE) { + $settings = $this->getDrupalSettings(); + $this->assertIdentical($library_is_present, isset($settings['ajaxPageState']) && in_array('tracker/history', explode(',', $settings['ajaxPageState']['libraries'])), 'drupal.tracker-history library is present.'); + $this->assertIdentical(1, count($this->xpath('//table/tbody/tr/td[@data-history-node-id="' . $node_id . '" and @data-history-node-timestamp="' . $node_timestamp . '"]')), 'Tracker table cell contains the data-history-node-id and data-history-node-timestamp attributes for the node.'); + $this->assertIdentical(1, count($this->xpath('//table/tbody/tr/td[@data-history-node-last-comment-timestamp="' . $node_last_comment_timestamp . '"]')), 'Tracker table cell contains the data-history-node-last-comment-timestamp attribute for the node.'); + } + } diff --git a/core/modules/tracker/tracker.libraries.yml b/core/modules/tracker/tracker.libraries.yml new file mode 100644 index 0000000..11c103c --- /dev/null +++ b/core/modules/tracker/tracker.libraries.yml @@ -0,0 +1,8 @@ +history: + version: VERSION + js: + js/tracker-history.js: {} + dependencies: + - core/jquery + - core/drupal + - history/api diff --git a/core/modules/tracker/tracker.pages.inc b/core/modules/tracker/tracker.pages.inc index 84a3366..3be3966 100644 --- a/core/modules/tracker/tracker.pages.inc +++ b/core/modules/tracker/tracker.pages.inc @@ -63,6 +63,13 @@ function tracker_page($account = NULL) { else { $nodes[$nid]->comment_count += $statistics->comment_count; } + // Make the last comment timestamp reflect the latest comment. + if (!isset($nodes[$nid]->last_comment_timestamp)) { + $nodes[$nid]->last_comment_timestamp = $statistics->last_comment_timestamp; + } + else { + $nodes[$nid]->last_comment_timestamp = max($nodes[$nid]->last_comment_timestamp, $statistics->last_comment_timestamp); + } } // Display the data. @@ -75,25 +82,8 @@ function tracker_page($account = NULL) { $comments = 0; if ($node->comment_count) { $comments = $node->comment_count; - - if ($new = \Drupal::service('comment.manager')->getCountNewComments($node)) { - $comments = array( - '#type' => 'link', - '#url' => $node->urlInfo(), - '#title' => \Drupal::translation()->formatPlural($new, '1 new', '@count new'), - '#options' => array( - 'fragment' => 'new', - ), - '#prefix' => $node->comment_count . '
', - ); - } } - $mark_build = array( - '#theme' => 'mark', - '#status' => node_mark($node->id(), $node->getChangedTime()), - ); - $row = array( 'type' => SafeMarkup::checkPlain(node_get_type_label($node)), 'title' => array( @@ -101,8 +91,9 @@ function tracker_page($account = NULL) { '#type' => 'link', '#url' => $node->urlInfo(), '#title' => $node->getTitle(), - '#suffix' => ' ' . drupal_render($mark_build), ), + 'data-history-node-id' => $node->id(), + 'data-history-node-timestamp' => $node->getChangedTime(), ), 'author' => array( 'data' => array( @@ -113,6 +104,7 @@ function tracker_page($account = NULL) { 'comments' => array( 'class' => array('comments'), 'data' => $comments, + 'data-history-node-last-comment-timestamp' => $node->last_comment_timestamp, ), 'last updated' => array( 'data' => t('!time ago', array( @@ -148,5 +140,9 @@ function tracker_page($account = NULL) { $page['#cache']['tags'] = $cache_tags; $page['#cache']['contexts'][] = 'user.node_grants:view'; + if (Drupal::moduleHandler()->moduleExists('history') && \Drupal::currentUser()->isAuthenticated()) { + $page['#attached']['library'][] = 'tracker/history'; + } + return $page; }