Commit 8f738c84 authored by catch's avatar catch

Issue #2844594 by alexpott, scott_euser, timmillwood, Sam152: Default workflow...

Issue #2844594 by alexpott, scott_euser, timmillwood, Sam152: Default workflow states and transitions
parent 172e9e38
......@@ -17,12 +17,30 @@
* @WorkflowType(
* id = "content_moderation",
* label = @Translation("Content moderation"),
* required_states = {
* "draft",
* "published",
* },
* )
*/
class ContentModeration extends WorkflowTypeBase {
use StringTranslationTrait;
/**
* {@inheritdoc}
*/
public function initializeWorkflow(WorkflowInterface $workflow) {
$workflow
->addState('draft', $this->t('Draft'))
->setStateWeight('draft', -5)
->addState('published', $this->t('Published'))
->setStateWeight('published', 0)
->addTransition('create_new_draft', $this->t('Create New Draft'), ['draft', 'published'], 'draft')
->addTransition('publish', $this->t('Publish'), ['draft', 'published'], 'published');
return $workflow;
}
/**
* {@inheritdoc}
*/
......@@ -51,12 +69,15 @@ public function decorateState(StateInterface $state) {
*/
public function buildStateConfigurationForm(FormStateInterface $form_state, WorkflowInterface $workflow, StateInterface $state = NULL) {
/** @var \Drupal\content_moderation\ContentModerationState $state */
$is_required_state = isset($state) ? in_array($state->id(), $this->getRequiredStates(), TRUE) : FALSE;
$form = [];
$form['published'] = [
'#type' => 'checkbox',
'#title' => $this->t('Published'),
'#description' => $this->t('When content reaches this state it should be published.'),
'#default_value' => isset($state) ? $state->isPublishedState() : FALSE,
'#disabled' => $is_required_state,
];
$form['default_revision'] = [
......@@ -64,6 +85,7 @@ public function buildStateConfigurationForm(FormStateInterface $form_state, Work
'#title' => $this->t('Default revision'),
'#description' => $this->t('When content reaches this state it should be made the default revision; this is implied for published states.'),
'#default_value' => isset($state) ? $state->isDefaultRevisionState() : FALSE,
'#disabled' => $is_required_state,
// @todo Add form #state to force "make default" on when "published" is
// on for a state.
// @see https://www.drupal.org/node/2645614
......@@ -156,7 +178,16 @@ public function addEntityTypeAndBundle($entity_type_id, $bundle_id) {
public function defaultConfiguration() {
// This plugin does not store anything per transition.
return [
'states' => [],
'states' => [
'draft' => [
'published' => FALSE,
'default_revision' => FALSE,
],
'published' => [
'published' => TRUE,
'default_revision' => TRUE,
],
],
'entity_types' => [],
];
}
......@@ -169,4 +200,15 @@ public function calculateDependencies() {
return [];
}
/**
* {@inheritdoc}
*/
public function getConfiguration() {
$configuration = parent::getConfiguration();
// Ensure that states and entity types are ordered consistently.
ksort($configuration['states']);
ksort($configuration['entity_types']);
return $configuration;
}
}
......@@ -4,7 +4,6 @@
use Drupal\Core\Url;
use Drupal\node\Entity\Node;
use Drupal\workflows\Entity\Workflow;
/**
* Tests general content moderation workflow for nodes.
......@@ -71,21 +70,6 @@ public function testCreatingContent() {
$this->fail('Non-moderated test node was not saved correctly.');
}
$this->assertEqual(NULL, $node->moderation_state->value);
// \Drupal\content_moderation\Form\BundleModerationConfigurationForm()
// should not list workflows with no states.
$workflow = Workflow::create(['id' => 'stateless', 'label' => 'Stateless', 'type' => 'content_moderation']);
$workflow->save();
$this->drupalGet('admin/structure/types/manage/moderated_content/moderation');
$this->assertNoText('Stateless');
$workflow
->addState('draft', 'Draft')
->addState('published', 'Published')
->addTransition('publish', 'Publish', ['draft', 'published'], 'published')
->save();
$this->drupalGet('admin/structure/types/manage/moderated_content/moderation');
$this->assertText('Stateless');
}
/**
......
......@@ -43,13 +43,19 @@ public function testNewWorkflow() {
'id' => 'test_workflow',
'workflow_type' => 'content_moderation',
], 'Save');
$this->assertSession()->pageTextContains('Created the Test Workflow Workflow. In order for the workflow to be enabled there needs to be at least one state.');
// Make sure the test workflow includes the default states and transitions.
$this->assertSession()->pageTextContains('Draft');
$this->assertSession()->pageTextContains('Published');
$this->assertSession()->pageTextContains('Create New Draft');
$this->assertSession()->pageTextContains('Publish');
// Ensure after a workflow is created, the bundle information can be
// refreshed.
$entity_bundle_info->clearCachedBundles();
$this->assertNotEmpty($entity_bundle_info->getAllBundleInfo());
$this->clickLink('Add a new state');
$this->submitForm([
'label' => 'Test State',
'id' => 'test_state',
......@@ -57,6 +63,16 @@ public function testNewWorkflow() {
'type_settings[content_moderation][default_revision]' => FALSE,
], 'Save');
$this->assertSession()->pageTextContains('Created Test State state.');
// Ensure that the published settings cannot be changed.
$this->drupalGet('admin/config/workflow/workflows/manage/test_workflow/state/published');
$this->assertSession()->fieldDisabled('type_settings[content_moderation][published]');
$this->assertSession()->fieldDisabled('type_settings[content_moderation][default_revision]');
// Ensure that the draft settings cannot be changed.
$this->drupalGet('admin/config/workflow/workflows/manage/test_workflow/state/draft');
$this->assertSession()->fieldDisabled('type_settings[content_moderation][published]');
$this->assertSession()->fieldDisabled('type_settings[content_moderation][default_revision]');
}
}
......@@ -70,11 +70,13 @@ public function permissionsTestCases() {
],
],
'states' => [
'published' => [
'label' => 'Published',
],
'draft' => [
'label' => 'Draft',
'weight' => -5,
],
'published' => [
'label' => 'Published',
'weight' => 0,
],
],
],
......@@ -101,11 +103,13 @@ public function permissionsTestCases() {
],
],
'states' => [
'tired' => [
'label' => 'Tired',
],
'awake' => [
'label' => 'Awake',
'weight' => -5,
],
'tired' => [
'label' => 'Tired',
'weight' => -0,
],
],
],
......
......@@ -41,4 +41,13 @@ class WorkflowType extends Plugin {
*/
public $label = '';
/**
* States required to exist.
*
* Normally supplied by WorkflowType::defaultConfiguration().
*
* @var array
*/
public $required_states = [];
}
......@@ -3,8 +3,10 @@
namespace Drupal\workflows\Entity;
use Drupal\Core\Config\Entity\ConfigEntityBase;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityWithPluginCollectionInterface;
use Drupal\Core\Plugin\DefaultSingleLazyPluginCollection;
use Drupal\workflows\Exception\RequiredStateMissingException;
use Drupal\workflows\State;
use Drupal\workflows\Transition;
use Drupal\workflows\WorkflowInterface;
......@@ -112,6 +114,18 @@ class Workflow extends ConfigEntityBase implements WorkflowInterface, EntityWith
*/
protected $pluginCollection;
/**
* {@inheritdoc}
*/
public function preSave(EntityStorageInterface $storage) {
$workflow_type = $this->getTypePlugin();
$missing_states = array_diff($workflow_type->getRequiredStates(), array_keys($this->getStates()));
if (!empty($missing_states)) {
throw new RequiredStateMissingException(sprintf("Workflow type '{$workflow_type->label()}' requires states with the ID '%s' in workflow '{$this->id()}'", implode("', '", $missing_states)));
}
parent::preSave($storage);
}
/**
* {@inheritdoc}
*/
......
<?php
namespace Drupal\workflows\Exception;
use Drupal\Core\Config\ConfigException;
/**
* Indicates that a workflow does not contain a required state.
*/
class RequiredStateMissingException extends ConfigException {
}
......@@ -84,11 +84,22 @@ public function form(array $form, FormStateInterface $form_state) {
public function save(array $form, FormStateInterface $form_state) {
/* @var \Drupal\workflows\WorkflowInterface $workflow */
$workflow = $this->entity;
$workflow->save();
drupal_set_message($this->t('Created the %label Workflow. In order for the workflow to be enabled there needs to be at least one state.', [
'%label' => $workflow->label(),
]));
$form_state->setRedirectUrl($workflow->toUrl('add-state-form'));
// Initialize the workflow using the selected type plugin.
$workflow = $workflow->getTypePlugin()->initializeWorkflow($workflow);
$return = $workflow->save();
if (empty($workflow->getStates())) {
drupal_set_message($this->t('Created the %label Workflow. In order for the workflow to be enabled there needs to be at least one state.', [
'%label' => $workflow->label(),
]));
$form_state->setRedirectUrl($workflow->toUrl('add-state-form'));
}
else {
drupal_set_message($this->t('Created the %label Workflow.', [
'%label' => $workflow->label(),
]));
$form_state->setRedirectUrl($workflow->toUrl('edit-form'));
}
return $return;
}
/**
......
......@@ -78,14 +78,15 @@ public function form(array $form, FormStateInterface $form_state) {
);
}
$delete_state_access = $this->entity->access('delete-state');
foreach ($states as $state) {
$links['edit'] = [
'title' => $this->t('Edit'),
'url' => Url::fromRoute('entity.workflow.edit_state_form', ['workflow' => $workflow->id(), 'workflow_state' => $state->id()]),
'attributes' => ['aria-label' => $this->t('Edit @state state', ['@state' => $state->label()])],
$links = [
'edit' => [
'title' => $this->t('Edit'),
'url' => Url::fromRoute('entity.workflow.edit_state_form', ['workflow' => $workflow->id(), 'workflow_state' => $state->id()]),
'attributes' => ['aria-label' => $this->t('Edit @state state', ['@state' => $state->label()])],
]
];
if ($delete_state_access) {
if ($this->entity->access('delete-state:' . $state->id())) {
$links['delete'] = [
'title' => t('Delete'),
'url' => Url::fromRoute('entity.workflow.delete_state_form', [
......
......@@ -151,7 +151,7 @@ protected function actions(array $form, FormStateInterface $form_state) {
$actions['delete'] = [
'#type' => 'link',
'#title' => $this->t('Delete'),
'#access' => $this->entity->access('delete-state'),
'#access' => $this->entity->access('delete-state:' . $this->stateId),
'#attributes' => [
'class' => ['button', 'button--danger'],
],
......
......@@ -31,6 +31,13 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition
$this->setConfiguration($configuration);
}
/**
* {@inheritdoc}
*/
public function initializeWorkflow(WorkflowInterface $workflow) {
return $workflow;
}
/**
* {@inheritdoc}
*/
......@@ -107,6 +114,13 @@ public function setConfiguration(array $configuration) {
);
}
/**
* {@inheritdoc}
*/
public function getRequiredStates() {
return $this->getPluginDefinition()['required_states'];
}
/**
* {@inheritDoc}
*/
......
......@@ -56,17 +56,21 @@ public function __construct(EntityTypeInterface $entity_type, PluginManagerInter
* {@inheritdoc}
*/
protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
if ($operation === 'delete-state') {
/** @var \Drupal\workflows\Entity\Workflow $entity */
$workflow_type = $entity->getTypePlugin();
if (strpos($operation, 'delete-state') === 0) {
list(, $state_id) = explode(':', $operation, 2);
// Deleting a state is editing a workflow, but also we should forbid
// access if there is only one state.
/** @var \Drupal\workflows\Entity\Workflow $entity */
$admin_access = AccessResult::allowedIf(count($entity->getStates()) > 1)->andIf(parent::checkAccess($entity, 'edit', $account))->addCacheableDependency($entity);
$admin_access = AccessResult::allowedIf(count($entity->getStates()) > 1)
->andIf(parent::checkAccess($entity, 'edit', $account))
->andIf(AccessResult::allowedIf(!in_array($state_id, $workflow_type->getRequiredStates(), TRUE)))
->addCacheableDependency($entity);
}
else {
$admin_access = parent::checkAccess($entity, $operation, $account);
}
/** @var \Drupal\workflows\WorkflowInterface $entity */
return $entity->getTypePlugin()->checkWorkflowAccess($entity, $operation, $account)->orIf($admin_access);
return $workflow_type->checkWorkflowAccess($entity, $operation, $account)->orIf($admin_access);
}
/**
......
<?php
namespace Drupal\workflows;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
use Symfony\Component\Routing\Route;
/**
* Provides a access checker for deleting a workflow state.
*/
class WorkflowDeleteAccessCheck implements AccessInterface {
/**
* Checks access to deleting a workflow state for a particular route.
*
* The value of '_workflow_state_delete_access' is ignored. The route must
* have the parameters 'workflow' and 'workflow_state'. For example:
* @code
* pattern: '/foo/{workflow}/bar/{workflow_state}/delete'
* requirements:
* _workflow_state_delete_access: 'true'
* @endcode
* @see \Drupal\Core\ParamConverter\EntityConverter
*
* @param \Symfony\Component\Routing\Route $route
* The route to check against.
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The parametrized route
* @param \Drupal\Core\Session\AccountInterface $account
* The currently logged in account.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public function access(Route $route, RouteMatchInterface $route_match, AccountInterface $account) {
// If there is valid entity of the given entity type, check its access.
$parameters = $route_match->getParameters();
if ($parameters->has('workflow') && $parameters->has('workflow_state')) {
$entity = $parameters->get('workflow');
if ($entity instanceof EntityInterface) {
return $entity->access('delete-state:' . $parameters->get('workflow_state'), $account, TRUE);
}
}
// No opinion, so other access checks should decide if access should be
// allowed or not.
return AccessResult::neutral();
}
}
......@@ -17,6 +17,21 @@
*/
interface WorkflowTypeInterface extends PluginInspectionInterface, DerivativeInspectionInterface, ConfigurablePluginInterface {
/**
* Initializes a workflow.
*
* Used to create required states and default transitions.
*
* @param \Drupal\workflows\WorkflowInterface $workflow
* The workflow to initialize.
*
* @return \Drupal\workflows\WorkflowInterface $workflow
* The initialized workflow.
*
* @see \Drupal\workflows\Form\WorkflowAddForm::save()
*/
public function initializeWorkflow(WorkflowInterface $workflow);
/**
* Gets the label for the workflow type.
*
......@@ -117,4 +132,16 @@ public function buildStateConfigurationForm(FormStateInterface $form_state, Work
*/
public function buildTransitionConfigurationForm(FormStateInterface $form_state, WorkflowInterface $workflow, TransitionInterface $transition = NULL);
/**
* Gets the required states of workflow type.
*
* This are usually configured in the workflow type annotation.
*
* @return array[]
* The required states.
*
* @see \Drupal\workflows\Annotation\WorkflowType
*/
public function getRequiredStates();
}
......@@ -7,6 +7,15 @@ workflow.type_settings.workflow_type_test:
sequence:
type: ignore
workflow.type_settings.workflow_type_required_state_test:
type: mapping
label: 'Workflow test type settings'
mapping:
states:
type: sequence
sequence:
type: ignore
workflow.type_settings.workflow_type_complex_test:
type: mapping
label: 'Workflow complex test type settings'
......
<?php
namespace Drupal\workflow_type_test\Plugin\WorkflowType;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\workflows\Plugin\WorkflowTypeBase;
use Drupal\workflows\WorkflowInterface;
/**
* Test workflow type.
*
* @WorkflowType(
* id = "workflow_type_required_state_test",
* label = @Translation("Required State Type Test"),
* required_states = {
* "fresh",
* "rotten",
* }
* )
*/
class RequiredStateTestType extends WorkflowTypeBase {
use StringTranslationTrait;
/**
* {@inheritdoc}
*/
public function initializeWorkflow(WorkflowInterface $workflow) {
$workflow
->addState('fresh', $this->t('Fresh'))
->setStateWeight('fresh', -5)
->addState('rotten', $this->t('Rotten'))
->addTransition('rot', $this->t('Rot'), ['fresh'], 'rotten');
return $workflow;
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
// No configuration is stored for the test type.
return [];
}
}
......@@ -22,4 +22,13 @@ public function defaultConfiguration() {
return [];
}
/**
* {@inheritdoc}
*/
public function getRequiredStates() {
// Normally this is obtained from the annotation but we get from state to
// allow dynamic testing.
return \Drupal::state()->get('workflow_type_test.required_states', []);
}
}
......@@ -66,10 +66,16 @@ public function testAccess() {
$this->assertSession()->statusCodeEquals(200);
}
// Ensure that default states can not be deleted.
\Drupal::state()->set('workflow_type_test.required_states', ['published']);
$this->drupalGet('admin/config/workflow/workflows/manage/test/state/published/delete');
$this->assertSession()->statusCodeEquals(403);
\Drupal::state()->set('workflow_type_test.required_states', []);
// Delete one of the states and ensure the other test cannot be deleted.
$this->drupalGet('admin/config/workflow/workflows/manage/test/state/published/delete');
$this->submitForm([], 'Delete');
$this->drupalGet('admin/config/workflow/workflows/manage/test/state/published/delete');
$this->drupalGet('admin/config/workflow/workflows/manage/test/state/draft/delete');
$this->assertSession()->statusCodeEquals(403);
}
......@@ -189,9 +195,28 @@ public function testWorkflowCreation() {
// the draft state.
$published_delete_link = Url::fromRoute('entity.workflow.delete_state_form', [
'workflow' => $workflow->id(),
'workflow_state' => 'published'
'workflow_state' => 'published',
])->toString();
$draft_delete_link = Url::fromRoute('entity.workflow.delete_state_form', [
'workflow' => $workflow->id(),
'workflow_state' => 'draft',
])->toString();
$this->assertSession()->elementContains('css', 'tr[data-drupal-selector="edit-states-published"]', 'Delete');
$this->assertSession()->linkByHrefExists($published_delete_link);
$this->assertSession()->linkByHrefExists($draft_delete_link);
// Make the published state a default state and ensure it is no longer
// linked.
\Drupal::state()->set('workflow_type_test.required_states', ['published']);
$this->getSession()->reload();
$this->assertSession()->linkByHrefNotExists($published_delete_link);
$this->assertSession()->linkByHrefExists($draft_delete_link);
$this->assertSession()->elementNotContains('css', 'tr[data-drupal-selector="edit-states-published"]', 'Delete');
\Drupal::state()->set('workflow_type_test.required_states', []);
$this->getSession()->reload();
$this->assertSession()->elementContains('css', 'tr[data-drupal-selector="edit-states-published"]', 'Delete');
$this->assertSession()->linkByHrefExists($published_delete_link);
$this->assertSession()->linkByHrefExists($draft_delete_link);
// Delete the Draft state.
$this->clickLink('Delete');
......@@ -211,6 +236,20 @@ public function testWorkflowCreation() {
$this->assertSession()->pageTextContains('Workflow Test deleted.');
$this->assertSession()->pageTextContains('There is no Workflow yet.');
$this->assertNull($workflow_storage->loadUnchanged('test'), 'The test workflow has been deleted');
// Ensure that workflow types that implement
// \Drupal\workflows\WorkflowTypeInterface::initializeWorkflow() are
// initialized correctly.
$this->drupalGet('admin/config/workflow/workflows');
$this->clickLink('Add workflow');
$this->submitForm(['label' => 'Test 2', 'id' => 'test2', 'workflow_type' => 'workflow_type_required_state_test'], 'Save');
$this->assertSession()->addressEquals('admin/config/workflow/workflows/manage/test2');
$workflow = $workflow_storage->loadUnchanged('test2');
$this->assertTrue($workflow->hasState('fresh'), 'The workflow has the "fresh" state');
$this->assertTrue($workflow->hasState('rotten'), 'The workflow has the "rotten" state');
$this->assertTrue($workflow->hasTransition('rot'), 'The workflow has the "rot" transition');
$this->assertSession()->pageTextContains('Fresh');
$this->assertSession()->pageTextContains('Rotten');
}
/**
......
<?php
namespace Drupal\Tests\workflows\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\workflows\Entity\Workflow;
/**
* Tests Workflow type's required states and configuration initialization.
*
* @coversDefaultClass \Drupal\workflows\Plugin\WorkflowTypeBase
*
* @group workflows
*/
class RequiredStatesTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
public static $modules = ['workflows', 'workflow_type_test'];
/**
* @covers ::getRequiredStates
* @covers ::initializeWorkflow
* @covers ::__construct
*/
public function testGetRequiredStates() {
$workflow = new Workflow([
'id' => 'test',
'type' => 'workflow_type_required_state_test',
], 'workflow');
$workflow = $workflow->getTypePlugin()->initializeWorkflow($workflow);
$workflow->save();
$this->assertEquals(['fresh', 'rotten'], $workflow->getTypePlugin()
->getRequiredStates());
// Ensure that the workflow has the default configuration.
$this->assertTrue($workflow->hasState('rotten'));
$this->assertTrue($workflow->hasState('fresh'));
$this->assertTrue($workflow->hasTransitionFromStateToState('fresh', 'rotten'));
}
/**
* @covers \Drupal\workflows\Entity\Workflow::preSave
* @expectedException \Drupal\workflows\Exception\RequiredStateMissingException
* @expectedExceptionMessage Required State Type Test' requires states with the ID 'fresh' in workflow 'test'
*/
public function testDeleteRequiredStateAPI() {
$workflow = new Workflow([
'id' => 'test',
'type' => 'workflow_type_required_state_test',
], 'workflow');
$workflow = $workflow->getTypePlugin()->initializeWorkflow($workflow);
$workflow->save();
// Ensure that required states can't be deleted.
$workflow->deleteState('fresh')->save();
}
/**
* @covers \Drupal\workflows\Entity\Workflow::preSave
* @expectedException \Drupal\workflows\Exception\RequiredStateMissingException
* @expectedExceptionMessage Required State Type Test' requires states with the ID 'fresh', 'rotten' in workflow 'test'
*/
public function testNoStatesRequiredStateAPI() {
$workflow = new Workflow([
'id' => 'test',
'type' => 'workflow_type_required_state_test',
], 'workflow');
$workflow->save();
}
/**
* Ensures that initialized configuration can be changed.
*/
public function testChangeRequiredStateAPI() {
$workflow = new Workflow([
'id' => 'test',
'type' => 'workflow_type_required_state_test',
], 'workflow');
$workflow = $workflow->getTypePlugin()->initializeWorkflow($workflow);
$workflow->save();
// Ensure states added by default configuration can be changed.
$this->assertEquals('Fresh', $workflow->getState('fresh')->label());
$workflow
->setStateLabel('fresh', 'Fresher')
->save();
$this->assertEquals('Fresher', $workflow->getState('fresh')->label());