diff --git a/core/modules/moderation/moderation.info.yml b/core/modules/moderation/moderation.info.yml new file mode 100644 index 0000000..b963683 --- /dev/null +++ b/core/modules/moderation/moderation.info.yml @@ -0,0 +1,8 @@ +name: Moderation +type: module +description: 'Moderate revisionable entities between states.' +package: Core +version: VERSION +core: 8.x +dependencies: + - options diff --git a/core/modules/moderation/moderation.links.task.yml b/core/modules/moderation/moderation.links.task.yml new file mode 100644 index 0000000..7957ee9 --- /dev/null +++ b/core/modules/moderation/moderation.links.task.yml @@ -0,0 +1,4 @@ +node.draft: + title: Draft + route_name: node.draft + base_route: entity.node.canonical diff --git a/core/modules/moderation/moderation.module b/core/modules/moderation/moderation.module new file mode 100644 index 0000000..a264243 --- /dev/null +++ b/core/modules/moderation/moderation.module @@ -0,0 +1,49 @@ +getHandlerClasses(); + $handlers['form']['default'] = 'Drupal\moderation\Form\NodeForm'; + $handlers['form']['edit'] = $handlers['form']['default']; + $entity_type->setHandlerClass('form', $handlers['form']); + } +} + +/** + * Determine if the given node has a draft. + * + * Draft is simply defined here as an unpublished revision that is newer than + * the published node. + * + * @param \Drupal\core\NodeInterface $node + * The node object. + * + * @return bool|int + * The revision ID if a draft exists, otherwise FALSE. + */ +function moderation_node_has_draft(NodeInterface $node) { + $current_revision = $node->getRevisionId(); + $node_storage = \Drupal::service('entity.manager')->getStorage('node'); + $vids = $node_storage->revisionIds($node); + + // Filter out vids less than or equal to current revision. + asort($vids); + $filtered = array_filter($vids, function ($var) use ($current_revision) { + return $var > $current_revision; + }); + return array_pop($filtered) ?: FALSE; +} diff --git a/core/modules/moderation/moderation.routing.yml b/core/modules/moderation/moderation.routing.yml new file mode 100644 index 0000000..b52bd23 --- /dev/null +++ b/core/modules/moderation/moderation.routing.yml @@ -0,0 +1,7 @@ +node.draft: + path: '/node/{node}/draft' + defaults: + _controller: 'Drupal\moderation\Controller\DraftController::show' + _title_callback: 'Drupal\moderation\Controller\DraftController::draftPageTitle' + requirements: + _access_node_draft: 'view' diff --git a/core/modules/moderation/moderation.services.yml b/core/modules/moderation/moderation.services.yml new file mode 100644 index 0000000..08193dc --- /dev/null +++ b/core/modules/moderation/moderation.services.yml @@ -0,0 +1,6 @@ +services: + access_check.node.draft: + class: Drupal\moderation\Access\DraftAccess + arguments: ['@entity.manager'] + tags: + - { name: access_check, applies_to: _access_node_draft } diff --git a/core/modules/moderation/src/Access/DraftAccess.php b/core/modules/moderation/src/Access/DraftAccess.php new file mode 100644 index 0000000..13bd137 --- /dev/null +++ b/core/modules/moderation/src/Access/DraftAccess.php @@ -0,0 +1,55 @@ +nodeStorage = $entity_manager->getStorage('node'); + $this->nodeAccess = $entity_manager->getAccessControlHandler('node'); + } + + /** + * {@inheritdoc} + */ + public function access(Route $route, AccountInterface $account, NodeInterface $node = NULL) { + // Check that the user has the ability to update the node, and that the node + // has a draft. + return AccessResult::allowedIf($node->access('update', $account) && moderation_node_has_draft($node)); + } + +} diff --git a/core/modules/moderation/src/Controller/DraftController.php b/core/modules/moderation/src/Controller/DraftController.php new file mode 100644 index 0000000..63f7111 --- /dev/null +++ b/core/modules/moderation/src/Controller/DraftController.php @@ -0,0 +1,34 @@ +revisionShow(moderation_node_has_draft($node)); + } + + /** + * Display the title of the draft. + */ + public function draftPageTitle(NodeInterface $node) { + return $this->revisionPageTitle(moderation_node_has_draft($node)); + } + +} diff --git a/core/modules/moderation/src/Form/NodeForm.php b/core/modules/moderation/src/Form/NodeForm.php new file mode 100644 index 0000000..4545a25 --- /dev/null +++ b/core/modules/moderation/src/Form/NodeForm.php @@ -0,0 +1,125 @@ +getEntity(); + if (!$node->isNew() && $node->type->entity->isNewRevision() && $revision_id = moderation_node_has_draft($node)) { + /** @var \Drupal\node\NodeStorage $storage */ + $storage = \Drupal::service('entity.manager')->getStorage('node'); + $this->entity = $storage->loadRevision($revision_id); + $this->isDraft = TRUE; + } + } + + /** + * {@inheritdoc} + */ + protected function actions(array $form, FormStateInterface $form_state) { + $element = parent::actions($form, $form_state); + + /** @var \Drupal\node\NodeInterface $node */ + $node = $this->getEntity(); + if (!$node->isNew() && $node->type->entity->isNewRevision() && $node->isPublished()) { + // Add a 'save as draft' action. + $element['draft'] = $element['submit']; + $element['draft']['#access'] = TRUE; + $element['draft']['#dropbutton'] = 'save'; + $element['draft']['#value'] = $this->t('Save as draft'); + // Setting to draft must be called before ::save, while setting the + // redirect must be done after. + array_unshift($element['draft']['#submit'], '::draft'); + $element['draft']['#submit'][] = '::setRedirect'; + + // Put the draft button first. + $element['draft']['#weight'] = -10; + + // If the user doesn't have 'administer nodes' permission, and this is + // a published node in a type that defaults to being unpublished, then + // only allow new drafts. + if (!\Drupal::currentUser()->hasPermission('administer nodes') && $this->nodeTypeUnpublishedDefault()) { + $element['submit']['#access'] = FALSE; + unset($element['draft']['#dropbutton']); + } + } + + // If this is an existing draft, change the publish button text. + if ($this->isDraft && isset($element['publish'])) { + $element['publish']['#value'] = t('Save and publish'); + } + + return $element; + } + + /** + * Save node as a draft. + */ + public function draft(array $form, FormStateInterface $form_state) { + $this->entity->isDefaultRevision(FALSE); + } + + /** + * Set default revision if this was previously a draft, and is now being + * published. + * + * {@inheritdoc} + */ + public function publish(array $form, FormStateInterface $form_state) { + $node = parent::publish($form, $form_state); + if ($this->isDraft) { + $node->isDefaultRevision(TRUE); + } + } + + /** + * Set a redirect to the draft. + */ + public function setRedirect(array $form, FormStateInterface $form_state) { + $form_state->setRedirect( + 'node.draft', + array('node' => $this->getEntity()->id()) + ); + } + + /** + * Helper function to determine unpublished default for a node type. + * + * @return bool + * Returns TRUE if the current node type is set to unpublished by default. + */ + protected function nodeTypeUnpublishedDefault() { + $type = $this->getEntity()->getType(); + // @todo Make it possible to get default values without an entity. + // https://www.drupal.org/node/2318187 + $node = $this->entityManager->getStorage('node')->create(array('type' => $type)); + return !$node->isPublished(); + } + +} + + diff --git a/core/modules/moderation/src/Tests/ModerationNodeTest.php b/core/modules/moderation/src/Tests/ModerationNodeTest.php new file mode 100644 index 0000000..aee316d --- /dev/null +++ b/core/modules/moderation/src/Tests/ModerationNodeTest.php @@ -0,0 +1,173 @@ +nodeType = NodeType::load('article'); + $this->nodeType->setNewRevision(TRUE); + $this->nodeType->save(); + + // Set unpublished by default. + $fields = \Drupal::entityManager()->getFieldDefinitions('node', 'article'); + $fields['status']->getConfig('article') + ->setDefaultValue(FALSE) + ->save(); + + // Set up users. + $this->adminUser = $this->drupalCreateUser(['bypass node access', 'administer nodes']); + $this->normalUser = $this->drupalCreateUser(['create article content', 'edit own article content']); + } + + /** + * Checks node edit functionality. + */ + function testBasicForwarRevisions() { + $this->drupalLogin($this->adminUser); + $this->drupalGet('node/add/article'); + + // For new content, the 'Save as draft' button should not be present. + $this->assertButtons([t('Save as unpublished'), t('Save and publish')]); + + // Create an unpublished node. + $unpublished_one_values = [ + // While standard to use randomName() for these values, human-readable + // text is easier to debug in this context. + 'title[0][value]' => 'Unpublished one title', + 'body[0][value]' => 'Unpublished one body', + ]; + $this->drupalPostForm('node/add/article', $unpublished_one_values, t('Save as unpublished')); + $node = $this->drupalGetNodeByTitle($unpublished_one_values['title[0][value]']); + + // Verify that the admin user can see the node's page. + $this->drupalGet('node/' . $node->id()); + $this->assertResponse(200, t('Draft is accessible to the admin user.')); + + // There should not be a draft tab since there is only a single unpublished + // revision. + $this->assertNoLink(t('Draft')); + + // Make another revision and publish. + $this->drupalGet('node/' . $node->id() .'/edit'); + $this->assertButtons([t('Save and keep unpublished'), t('Save and publish')]); + $published_one_values = [ + 'title[0][value]' => 'Published one title', + 'body[0][value]' => 'Published one title', + ]; + $this->drupalPostForm('node/' . $node->id() . '/edit', $published_one_values, t('Save and publish')); + + // Verify published values are on the node page. + $this->drupalGet('node/' . $node->id()); + $this->assertText($published_one_values['title[0][value]'], 'Published title found'); + $this->assertText($published_one_values['body[0][value]'], t('Published body found')); + + // There should still be no draft tab since the latest revision is + // published. + $this->assertNoLink(t('Draft')); + + // Create a draft. + $this->drupalGet('node/' . $node->id() . '/edit'); + $this->assertButtons([t('Save as draft'), t('Save and keep published'), t('Save and unpublish')]); + $draft_one_values = [ + 'title[0][value]' => 'Draft one title', + 'body[0][value]' => 'Draft one body', + ]; + $this->drupalPostForm('node/' . $node->id() . '/edit', $draft_one_values, t('Save as draft')); + + // The user should be redirected to the draft. + $this->assertUrl('node/' . $node->id() . '/draft', [], 'User is redirected to view the draft after saving.'); + + // Now there should be a draft tab. + $this->assertLink(t('Draft')); + + // Verify that the admin user can see the node's page. + $this->drupalGet('node/' . $node->id()); + $this->assertText($published_one_values['title[0][value]'], 'The published title stays the same when a new draft is created.'); + $this->assertText($published_one_values['body[0][value]'], 'The published body stays the same when a new draft is created.'); + + // The draft should be loaded in the edit form. + $this->drupalGet('node/' . $node->id() . '/edit'); + $this->assertRaw($draft_one_values['title[0][value]'], 'The draft title is loaded on the edit form.'); + $this->assertRaw($draft_one_values['body[0][value]'], 'The draft body is loaded on the edit form.'); + $this->assertButtons([t('Save as draft'), t('Save and publish'), t('Save and unpublish')]); + $this->drupalPostForm('node/' . $node->id() . '/edit', $draft_one_values, t('Save and publish')); + + // Publish the draft. + $this->drupalGet('node/' . $node->id()); + $this->assertText($draft_one_values['title[0][value]'], 'Published title found'); + $this->assertText($draft_one_values['body[0][value]'], t('Published body found')); + + // For normal users (eg, users without the administer nodes permission), if + // a content type is set to be unpublished by default, then on edits, only + // allow new drafts to be created, rather than allowing the published node + // to be updated. + $this->drupalLogin($this->normalUser); + $this->drupalGet('node/add/article'); + $this->assertButtons([t('Save')], FALSE); + + // Create a node and publish. + $this->drupalPostForm('node/add/article', $published_one_values, t('Save')); + $node = $this->drupalGetNodeByTitle($published_one_values['title[0][value]']); + $node->setPublished(TRUE)->save(); + + // Edit the node. + $this->drupalGet('node/' . $node->id() . '/edit'); + $this->assertButtons([t('Save as draft')], FALSE, t('Save as draft')); + $edit = [ + 'title[0][value]' => 'Draft one title', + 'body[0][value]' => 'Draft one body', + ]; + $this->drupalPostForm('node/' . $node->id() . '/edit', $edit, t('Save as draft')); + $this->assertLink(t('Draft')); + + // User with view, but not update, permissions, should not see the draft + // tab. + $this->drupalLogout(); + $this->drupalGet('node/' . $node->id()); + $this->assertNoLink(t('Draft'), 'The draft tab does not appear for users without update access.'); + + // And should not be able to access it directly either. + $this->drupalGet('node/' . $node->id() . '/draft'); + $this->assertResponse(403, 'Access is denied for the draft page for users without update access.'); + } + +} + diff --git a/core/modules/node/src/NodeForm.php b/core/modules/node/src/NodeForm.php index 17066ab..b64d77b 100644 --- a/core/modules/node/src/NodeForm.php +++ b/core/modules/node/src/NodeForm.php @@ -352,6 +352,9 @@ public function preview(array $form, FormStateInterface $form_state) { * An associative array containing the structure of the form. * @param $form_state * The current state of the form. + * + * @return \Drupal\node\NodeInterface + * The node object. */ public function publish(array $form, FormStateInterface $form_state) { $node = $this->entity; @@ -366,6 +369,9 @@ public function publish(array $form, FormStateInterface $form_state) { * An associative array containing the structure of the form. * @param $form_state * The current state of the form. + * + * @return \Drupal\node\NodeInterface + * The node object. */ public function unpublish(array $form, FormStateInterface $form_state) { $node = $this->entity; diff --git a/core/modules/node/src/Tests/AssertButtonsTrait.php b/core/modules/node/src/Tests/AssertButtonsTrait.php new file mode 100644 index 0000000..3d9b357 --- /dev/null +++ b/core/modules/node/src/Tests/AssertButtonsTrait.php @@ -0,0 +1,55 @@ +xpath('//input[@type="submit"][@value="' . $save_button_value . '"]'); + + // Verify that the number of buttons passed as parameters is + // available in the dropbutton widget. + if ($dropbutton) { + $i = 0; + $count = count($buttons); + + // Assert there is no save button. + $this->assertTrue(empty($save_button)); + + // Dropbutton elements. + $elements = $this->xpath('//div[@class="dropbutton-wrapper"]//input[@type="submit"]'); + $this->assertEqual($count, count($elements)); + foreach ($elements as $element) { + $value = isset($element['value']) ? (string) $element['value'] : ''; + $this->assertEqual($buttons[$i], $value); + $i++; + } + } + else { + // Assert there is a save button. + $this->assertTrue(!empty($save_button)); + $this->assertNoRaw('dropbutton-wrapper'); + } + } + +} diff --git a/core/modules/node/src/Tests/NodeFormButtonsTest.php b/core/modules/node/src/Tests/NodeFormButtonsTest.php index b5c2df5..8fe115a 100644 --- a/core/modules/node/src/Tests/NodeFormButtonsTest.php +++ b/core/modules/node/src/Tests/NodeFormButtonsTest.php @@ -7,6 +7,8 @@ namespace Drupal\node\Tests; +use Drupal\node\Tests\AssertButtonsTrait; + /** * Tests all the different buttons on the node form. * @@ -14,6 +16,8 @@ */ class NodeFormButtonsTest extends NodeTestBase { + use AssertButtonsTrait; + /** * A normal logged in user. * @@ -135,41 +139,4 @@ function testNodeFormButtons() { $this->assertFalse($node_3->isPublished(), 'Node is unpublished'); } - /** - * Assert method to verify the buttons in the dropdown element. - * - * @param array $buttons - * A collection of buttons to assert for on the page. - * @param bool $dropbutton - * Whether to check if the buttons are in a dropbutton widget or not. - */ - public function assertButtons($buttons, $dropbutton = TRUE) { - - // Try to find a Save button. - $save_button = $this->xpath('//input[@type="submit"][@value="Save"]'); - - // Verify that the number of buttons passed as parameters is - // available in the dropbutton widget. - if ($dropbutton) { - $i = 0; - $count = count($buttons); - - // Assert there is no save button. - $this->assertTrue(empty($save_button)); - - // Dropbutton elements. - $elements = $this->xpath('//div[@class="dropbutton-wrapper"]//input[@type="submit"]'); - $this->assertEqual($count, count($elements)); - foreach ($elements as $element) { - $value = isset($element['value']) ? (string) $element['value'] : ''; - $this->assertEqual($buttons[$i], $value); - $i++; - } - } - else { - // Assert there is a save button. - $this->assertTrue(!empty($save_button)); - $this->assertNoRaw('dropbutton-wrapper'); - } - } }