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..2800681 --- /dev/null +++ b/core/modules/moderation/moderation.module @@ -0,0 +1,25 @@ +getHandlerClasses(); + $handlers['form']['default'] = 'Drupal\moderation\Form\NodeForm'; + $handlers['form']['edit'] = $handlers['form']['default']; + $entity_type->setHandlerClass('form', $handlers['form']); + } +} 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..b7b91a4 --- /dev/null +++ b/core/modules/moderation/moderation.services.yml @@ -0,0 +1,9 @@ +services: + access_check.node.draft: + class: Drupal\moderation\Access\DraftAccess + arguments: ['@entity.manager','@moderation.node_moderation'] + tags: + - { name: access_check, applies_to: _access_node_draft } + moderation.node_moderation: + class: Drupal\moderation\NodeModeration + arguments: ['@entity_type.manager'] diff --git a/core/modules/moderation/src/Access/DraftAccess.php b/core/modules/moderation/src/Access/DraftAccess.php new file mode 100644 index 0000000..4efa712 --- /dev/null +++ b/core/modules/moderation/src/Access/DraftAccess.php @@ -0,0 +1,66 @@ +nodeStorage = $entity_manager->getStorage('node'); + $this->nodeAccess = $entity_manager->getAccessControlHandler('node'); + $this->moderation = $moderation; + } + + /** + * {@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) && $this->moderation->hasDraft($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..7e06a72 --- /dev/null +++ b/core/modules/moderation/src/Controller/DraftController.php @@ -0,0 +1,71 @@ +moderation = $moderation; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('date.formatter'), + $container->get('renderer'), + $container->get('moderation.node_moderation') + ); + } + + /** + * Display current revision denoted as a draft. + * + * @param \Drupal\node\NodeInterface + * The current node. + */ + public function show(NodeInterface $node) { + return $this->revisionShow($this->moderation->getDraftRevisionId($node)); + } + + /** + * Display the title of the draft. + */ + public function draftPageTitle(NodeInterface $node) { + return $this->revisionPageTitle($this->moderation->getDraftRevisionId($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..3eb1444 --- /dev/null +++ b/core/modules/moderation/src/Form/NodeForm.php @@ -0,0 +1,160 @@ +moderation = $moderation; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity.manager'), + $container->get('user.private_tempstore'), + $container->get('moderation.node_moderation') + ); + } + + /** + * Ensure proper node revision is used in the node form. + * + * {@inheritdoc} + */ + protected function prepareEntity() { + /** @var \Drupal\node\NodeInterface $node */ + $node = $this->getEntity(); + if (!$node->isNew() && $node->type->entity->isNewRevision() && $revision_id = $this->moderation->getDraftRevisionId($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', + ['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(['type' => $type]); + return !$node->isPublished(); + } + +} diff --git a/core/modules/moderation/src/ModerationInterface.php b/core/modules/moderation/src/ModerationInterface.php new file mode 100644 index 0000000..326fe44 --- /dev/null +++ b/core/modules/moderation/src/ModerationInterface.php @@ -0,0 +1,35 @@ +entityTypeManager = $entityTypeManager; + } + + /** + * {@inheritdoc} + */ + public function hasDraft(RevisionableInterface $entity) { + return (boolean) $this->getDraftRevisionId($entity); + } + + /** + * {@inheritdoc} + */ + public function getDraftRevisionId(RevisionableInterface $entity) { + $current_revision = $entity->getRevisionId(); + $entity_storage = $this->entityTypeManager->getStorage('node'); + $vids = $entity_storage->revisionIds($entity); + + // Filter out vids less than or equal to current revision. + $filtered = array_filter($vids, function ($vid) use ($current_revision) { + return $vid > $current_revision; + }); + + return array_pop($filtered) ?: 0; + } + +} diff --git a/core/modules/moderation/src/Tests/ModerationNodeTest.php b/core/modules/moderation/src/Tests/ModerationNodeTest.php new file mode 100644 index 0000000..f7818a2 --- /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.'); + } + +} +