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 @@ ...@@ -17,12 +17,30 @@
* @WorkflowType( * @WorkflowType(
* id = "content_moderation", * id = "content_moderation",
* label = @Translation("Content moderation"), * label = @Translation("Content moderation"),
* required_states = {
* "draft",
* "published",
* },
* ) * )
*/ */
class ContentModeration extends WorkflowTypeBase { class ContentModeration extends WorkflowTypeBase {
use StringTranslationTrait; 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} * {@inheritdoc}
*/ */
...@@ -51,12 +69,15 @@ public function decorateState(StateInterface $state) { ...@@ -51,12 +69,15 @@ public function decorateState(StateInterface $state) {
*/ */
public function buildStateConfigurationForm(FormStateInterface $form_state, WorkflowInterface $workflow, StateInterface $state = NULL) { public function buildStateConfigurationForm(FormStateInterface $form_state, WorkflowInterface $workflow, StateInterface $state = NULL) {
/** @var \Drupal\content_moderation\ContentModerationState $state */ /** @var \Drupal\content_moderation\ContentModerationState $state */
$is_required_state = isset($state) ? in_array($state->id(), $this->getRequiredStates(), TRUE) : FALSE;
$form = []; $form = [];
$form['published'] = [ $form['published'] = [
'#type' => 'checkbox', '#type' => 'checkbox',
'#title' => $this->t('Published'), '#title' => $this->t('Published'),
'#description' => $this->t('When content reaches this state it should be published.'), '#description' => $this->t('When content reaches this state it should be published.'),
'#default_value' => isset($state) ? $state->isPublishedState() : FALSE, '#default_value' => isset($state) ? $state->isPublishedState() : FALSE,
'#disabled' => $is_required_state,
]; ];
$form['default_revision'] = [ $form['default_revision'] = [
...@@ -64,6 +85,7 @@ public function buildStateConfigurationForm(FormStateInterface $form_state, Work ...@@ -64,6 +85,7 @@ public function buildStateConfigurationForm(FormStateInterface $form_state, Work
'#title' => $this->t('Default revision'), '#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.'), '#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, '#default_value' => isset($state) ? $state->isDefaultRevisionState() : FALSE,
'#disabled' => $is_required_state,
// @todo Add form #state to force "make default" on when "published" is // @todo Add form #state to force "make default" on when "published" is
// on for a state. // on for a state.
// @see https://www.drupal.org/node/2645614 // @see https://www.drupal.org/node/2645614
...@@ -156,7 +178,16 @@ public function addEntityTypeAndBundle($entity_type_id, $bundle_id) { ...@@ -156,7 +178,16 @@ public function addEntityTypeAndBundle($entity_type_id, $bundle_id) {
public function defaultConfiguration() { public function defaultConfiguration() {
// This plugin does not store anything per transition. // This plugin does not store anything per transition.
return [ return [
'states' => [], 'states' => [
'draft' => [
'published' => FALSE,
'default_revision' => FALSE,
],
'published' => [
'published' => TRUE,
'default_revision' => TRUE,
],
],
'entity_types' => [], 'entity_types' => [],
]; ];
} }
...@@ -169,4 +200,15 @@ public function calculateDependencies() { ...@@ -169,4 +200,15 @@ public function calculateDependencies() {
return []; 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 @@ ...@@ -4,7 +4,6 @@
use Drupal\Core\Url; use Drupal\Core\Url;
use Drupal\node\Entity\Node; use Drupal\node\Entity\Node;
use Drupal\workflows\Entity\Workflow;
/** /**
* Tests general content moderation workflow for nodes. * Tests general content moderation workflow for nodes.
...@@ -71,21 +70,6 @@ public function testCreatingContent() { ...@@ -71,21 +70,6 @@ public function testCreatingContent() {
$this->fail('Non-moderated test node was not saved correctly.'); $this->fail('Non-moderated test node was not saved correctly.');
} }
$this->assertEqual(NULL, $node->moderation_state->value); $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() { ...@@ -43,13 +43,19 @@ public function testNewWorkflow() {
'id' => 'test_workflow', 'id' => 'test_workflow',
'workflow_type' => 'content_moderation', 'workflow_type' => 'content_moderation',
], 'Save'); ], '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 // Ensure after a workflow is created, the bundle information can be
// refreshed. // refreshed.
$entity_bundle_info->clearCachedBundles(); $entity_bundle_info->clearCachedBundles();
$this->assertNotEmpty($entity_bundle_info->getAllBundleInfo()); $this->assertNotEmpty($entity_bundle_info->getAllBundleInfo());
$this->clickLink('Add a new state');
$this->submitForm([ $this->submitForm([
'label' => 'Test State', 'label' => 'Test State',
'id' => 'test_state', 'id' => 'test_state',
...@@ -57,6 +63,16 @@ public function testNewWorkflow() { ...@@ -57,6 +63,16 @@ public function testNewWorkflow() {
'type_settings[content_moderation][default_revision]' => FALSE, 'type_settings[content_moderation][default_revision]' => FALSE,
], 'Save'); ], 'Save');
$this->assertSession()->pageTextContains('Created Test State state.'); $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() { ...@@ -70,11 +70,13 @@ public function permissionsTestCases() {
], ],
], ],
'states' => [ 'states' => [
'published' => [
'label' => 'Published',
],
'draft' => [ 'draft' => [
'label' => 'Draft', 'label' => 'Draft',
'weight' => -5,
],
'published' => [
'label' => 'Published',
'weight' => 0,
], ],
], ],
], ],
...@@ -101,11 +103,13 @@ public function permissionsTestCases() { ...@@ -101,11 +103,13 @@ public function permissionsTestCases() {
], ],
], ],
'states' => [ 'states' => [
'tired' => [
'label' => 'Tired',
],
'awake' => [ 'awake' => [
'label' => 'Awake', 'label' => 'Awake',
'weight' => -5,
],
'tired' => [
'label' => 'Tired',
'weight' => -0,
], ],
], ],
], ],
......
...@@ -41,4 +41,13 @@ class WorkflowType extends Plugin { ...@@ -41,4 +41,13 @@ class WorkflowType extends Plugin {
*/ */
public $label = ''; public $label = '';
/**
* States required to exist.
*
* Normally supplied by WorkflowType::defaultConfiguration().
*
* @var array
*/
public $required_states = [];
} }
...@@ -3,8 +3,10 @@ ...@@ -3,8 +3,10 @@
namespace Drupal\workflows\Entity; namespace Drupal\workflows\Entity;
use Drupal\Core\Config\Entity\ConfigEntityBase; use Drupal\Core\Config\Entity\ConfigEntityBase;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityWithPluginCollectionInterface; use Drupal\Core\Entity\EntityWithPluginCollectionInterface;
use Drupal\Core\Plugin\DefaultSingleLazyPluginCollection; use Drupal\Core\Plugin\DefaultSingleLazyPluginCollection;
use Drupal\workflows\Exception\RequiredStateMissingException;
use Drupal\workflows\State; use Drupal\workflows\State;
use Drupal\workflows\Transition; use Drupal\workflows\Transition;
use Drupal\workflows\WorkflowInterface; use Drupal\workflows\WorkflowInterface;
...@@ -112,6 +114,18 @@ class Workflow extends ConfigEntityBase implements WorkflowInterface, EntityWith ...@@ -112,6 +114,18 @@ class Workflow extends ConfigEntityBase implements WorkflowInterface, EntityWith
*/ */
protected $pluginCollection; 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} * {@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) { ...@@ -84,11 +84,22 @@ public function form(array $form, FormStateInterface $form_state) {
public function save(array $form, FormStateInterface $form_state) { public function save(array $form, FormStateInterface $form_state) {
/* @var \Drupal\workflows\WorkflowInterface $workflow */ /* @var \Drupal\workflows\WorkflowInterface $workflow */
$workflow = $this->entity; $workflow = $this->entity;
$workflow->save(); // Initialize the workflow using the selected type plugin.
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.', [ $workflow = $workflow->getTypePlugin()->initializeWorkflow($workflow);
'%label' => $workflow->label(), $return = $workflow->save();
])); if (empty($workflow->getStates())) {
$form_state->setRedirectUrl($workflow->toUrl('add-state-form')); 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) { ...@@ -78,14 +78,15 @@ public function form(array $form, FormStateInterface $form_state) {
); );
} }
$delete_state_access = $this->entity->access('delete-state');
foreach ($states as $state) { foreach ($states as $state) {
$links['edit'] = [ $links = [
'title' => $this->t('Edit'), 'edit' => [
'url' => Url::fromRoute('entity.workflow.edit_state_form', ['workflow' => $workflow->id(), 'workflow_state' => $state->id()]), 'title' => $this->t('Edit'),
'attributes' => ['aria-label' => $this->t('Edit @state state', ['@state' => $state->label()])], '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'] = [ $links['delete'] = [
'title' => t('Delete'), 'title' => t('Delete'),
'url' => Url::fromRoute('entity.workflow.delete_state_form', [ 'url' => Url::fromRoute('entity.workflow.delete_state_form', [
......
...@@ -151,7 +151,7 @@ protected function actions(array $form, FormStateInterface $form_state) { ...@@ -151,7 +151,7 @@ protected function actions(array $form, FormStateInterface $form_state) {
$actions['delete'] = [ $actions['delete'] = [
'#type' => 'link', '#type' => 'link',
'#title' => $this->t('Delete'), '#title' => $this->t('Delete'),
'#access' => $this->entity->access('delete-state'), '#access' => $this->entity->access('delete-state:' . $this->stateId),
'#attributes' => [ '#attributes' => [
'class' => ['button', 'button--danger'], 'class' => ['button', 'button--danger'],
], ],
......
...@@ -31,6 +31,13 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition ...@@ -31,6 +31,13 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition
$this->setConfiguration($configuration); $this->setConfiguration($configuration);
} }
/**
* {@inheritdoc}
*/
public function initializeWorkflow(WorkflowInterface $workflow) {
return $workflow;
}
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */
...@@ -107,6 +114,13 @@ public function setConfiguration(array $configuration) { ...@@ -107,6 +114,13 @@ public function setConfiguration(array $configuration) {
); );
} }
/**
* {@inheritdoc}
*/
public function getRequiredStates() {
return $this->getPluginDefinition()['required_states'];
}
/** /**
* {@inheritDoc} * {@inheritDoc}
*/ */
......
...@@ -56,17 +56,21 @@ public function __construct(EntityTypeInterface $entity_type, PluginManagerInter ...@@ -56,17 +56,21 @@ public function __construct(EntityTypeInterface $entity_type, PluginManagerInter
* {@inheritdoc} * {@inheritdoc}
*/ */
protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) { 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 // Deleting a state is editing a workflow, but also we should forbid
// access if there is only one state. // access if there is only one state.
/** @var \Drupal\workflows\Entity\Workflow $entity */ $admin_access = AccessResult::allowedIf(count($entity->getStates()) > 1)
$admin_access = AccessResult::allowedIf(count($entity->getStates()) > 1)->andIf(parent::checkAccess($entity, 'edit', $account))->addCacheableDependency($entity); ->andIf(parent::checkAccess($entity, 'edit', $account))
->andIf(AccessResult::allowedIf(!in_array($state_id, $workflow_type->getRequiredStates(), TRUE)))
->addCacheableDependency($entity);
} }
else { else {
$admin_access = parent::checkAccess($entity, $operation, $account); $admin_access = parent::checkAccess($entity, $operation, $account);
} }
/** @var \Drupal\workflows\WorkflowInterface $entity */ return $workflow_type->checkWorkflowAccess($entity, $operation, $account)->orIf($admin_access);
return $entity->getTypePlugin()->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 @@ ...@@ -17,6 +17,21 @@
*/ */
interface WorkflowTypeInterface extends PluginInspectionInterface, DerivativeInspectionInterface, ConfigurablePluginInterface { 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. * Gets the label for the workflow type.
* *
...@@ -117,4 +132,16 @@ public function buildStateConfigurationForm(FormStateInterface $form_state, Work ...@@ -117,4 +132,16 @@ public function buildStateConfigurationForm(FormStateInterface $form_state, Work
*/ */
public function buildTransitionConfigurationForm(FormStateInterface $form_state, WorkflowInterface $workflow, TransitionInterface $transition = NULL); 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: ...@@ -7,6 +7,15 @@ workflow.type_settings.workflow_type_test:
sequence: sequence:
type: ignore 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: workflow.type_settings.workflow_type_complex_test:
type: mapping type: mapping
label: 'Workflow complex test type settings' label: 'Workflow complex test type settings'
......
<?php
namespace Drupal\workflow_type_test\Plugin\WorkflowType;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\workflows\Plugin\WorkflowTypeBase;