diff --git a/core/modules/content_moderation/src/Plugin/WorkflowType/ContentModeration.php b/core/modules/content_moderation/src/Plugin/WorkflowType/ContentModeration.php index a5d040190e04a43f23636cff7e7c56cd60d6303e..67743d41efc532c7b9fa6dada94d9ab9d961ca82 100644 --- a/core/modules/content_moderation/src/Plugin/WorkflowType/ContentModeration.php +++ b/core/modules/content_moderation/src/Plugin/WorkflowType/ContentModeration.php @@ -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; + } + } diff --git a/core/modules/content_moderation/src/Tests/ModerationStateNodeTest.php b/core/modules/content_moderation/src/Tests/ModerationStateNodeTest.php index a89ec9fd240a8e96179772d5b570eb3e6d367438..fe36336176f0c1d7f06d0fccbe953445c6800ab4 100644 --- a/core/modules/content_moderation/src/Tests/ModerationStateNodeTest.php +++ b/core/modules/content_moderation/src/Tests/ModerationStateNodeTest.php @@ -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'); } /** diff --git a/core/modules/content_moderation/tests/src/Functional/ContentModerationWorkflowTypeTest.php b/core/modules/content_moderation/tests/src/Functional/ContentModerationWorkflowTypeTest.php index b49439bf6da87a6cd10215e6c84a41a53b453898..ff37bd11a99d69be6d35b54ba49feaefcc915b14 100644 --- a/core/modules/content_moderation/tests/src/Functional/ContentModerationWorkflowTypeTest.php +++ b/core/modules/content_moderation/tests/src/Functional/ContentModerationWorkflowTypeTest.php @@ -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]'); } } diff --git a/core/modules/content_moderation/tests/src/Kernel/ContentModerationPermissionsTest.php b/core/modules/content_moderation/tests/src/Kernel/ContentModerationPermissionsTest.php index b20da5b47b51e33caa8b3ea9ab4e9830b63f895c..3a19d22d7a519893d8a17706a4148287fde258ef 100644 --- a/core/modules/content_moderation/tests/src/Kernel/ContentModerationPermissionsTest.php +++ b/core/modules/content_moderation/tests/src/Kernel/ContentModerationPermissionsTest.php @@ -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, ], ], ], diff --git a/core/modules/workflows/src/Annotation/WorkflowType.php b/core/modules/workflows/src/Annotation/WorkflowType.php index 2aa3ff9eb2953fcb24ead3bd1e3613588364ff73..6e578ed4b9ed313836557815194be8f6246189b8 100644 --- a/core/modules/workflows/src/Annotation/WorkflowType.php +++ b/core/modules/workflows/src/Annotation/WorkflowType.php @@ -41,4 +41,13 @@ class WorkflowType extends Plugin { */ public $label = ''; + /** + * States required to exist. + * + * Normally supplied by WorkflowType::defaultConfiguration(). + * + * @var array + */ + public $required_states = []; + } diff --git a/core/modules/workflows/src/Entity/Workflow.php b/core/modules/workflows/src/Entity/Workflow.php index 995ad3116535271d28e67dc7646845efd66c79d9..330c3922e59f26066763ab616eaa43560fb4a208 100644 --- a/core/modules/workflows/src/Entity/Workflow.php +++ b/core/modules/workflows/src/Entity/Workflow.php @@ -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} */ diff --git a/core/modules/workflows/src/Exception/RequiredStateMissingException.php b/core/modules/workflows/src/Exception/RequiredStateMissingException.php new file mode 100644 index 0000000000000000000000000000000000000000..7e88bbfe0e046513d98d62054ddc3de8f8bf1adc --- /dev/null +++ b/core/modules/workflows/src/Exception/RequiredStateMissingException.php @@ -0,0 +1,11 @@ +<?php + +namespace Drupal\workflows\Exception; + +use Drupal\Core\Config\ConfigException; + +/** + * Indicates that a workflow does not contain a required state. + */ +class RequiredStateMissingException extends ConfigException { +} diff --git a/core/modules/workflows/src/Form/WorkflowAddForm.php b/core/modules/workflows/src/Form/WorkflowAddForm.php index c779b8fec1a4a3ef97d346c815db7f1c1a649157..56cbf699617659b53779af7a3038a6538633423a 100644 --- a/core/modules/workflows/src/Form/WorkflowAddForm.php +++ b/core/modules/workflows/src/Form/WorkflowAddForm.php @@ -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; } /** diff --git a/core/modules/workflows/src/Form/WorkflowEditForm.php b/core/modules/workflows/src/Form/WorkflowEditForm.php index d8f99f935bf97f9a743b546ab875d48fdb7e0ba0..6b01920ada967828f4d5cb133dc41b8f91a83eeb 100644 --- a/core/modules/workflows/src/Form/WorkflowEditForm.php +++ b/core/modules/workflows/src/Form/WorkflowEditForm.php @@ -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', [ diff --git a/core/modules/workflows/src/Form/WorkflowStateEditForm.php b/core/modules/workflows/src/Form/WorkflowStateEditForm.php index b326404a416b229e491b76193085a18d56a21387..75af1d6b40eb831d5117a10853ebefa0b313539c 100644 --- a/core/modules/workflows/src/Form/WorkflowStateEditForm.php +++ b/core/modules/workflows/src/Form/WorkflowStateEditForm.php @@ -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'], ], diff --git a/core/modules/workflows/src/Plugin/WorkflowTypeBase.php b/core/modules/workflows/src/Plugin/WorkflowTypeBase.php index 1ed9fcaf134ab15433d2eb87f99ca21ec87ab220..15ced3e25a67152e1fe1f48c997f2da01a6bb317 100644 --- a/core/modules/workflows/src/Plugin/WorkflowTypeBase.php +++ b/core/modules/workflows/src/Plugin/WorkflowTypeBase.php @@ -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} */ diff --git a/core/modules/workflows/src/WorkflowAccessControlHandler.php b/core/modules/workflows/src/WorkflowAccessControlHandler.php index dbeedf521ef7c6f92c9b4b6d944eb6a3855e8f46..156f0091ccf85331c09eba6b8ff8db4339ae271e 100644 --- a/core/modules/workflows/src/WorkflowAccessControlHandler.php +++ b/core/modules/workflows/src/WorkflowAccessControlHandler.php @@ -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); } /** diff --git a/core/modules/workflows/src/WorkflowDeleteAccessCheck.php b/core/modules/workflows/src/WorkflowDeleteAccessCheck.php new file mode 100644 index 0000000000000000000000000000000000000000..df3b7dd27017c86bc00eaea23eec7487525d68ba --- /dev/null +++ b/core/modules/workflows/src/WorkflowDeleteAccessCheck.php @@ -0,0 +1,53 @@ +<?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(); + } + +} diff --git a/core/modules/workflows/src/WorkflowTypeInterface.php b/core/modules/workflows/src/WorkflowTypeInterface.php index 17fddecb98e71b35e488e4a1bc052c8c3e52ac1a..511cb9d211d413ced4e5b8cef237f2934ccfc668 100644 --- a/core/modules/workflows/src/WorkflowTypeInterface.php +++ b/core/modules/workflows/src/WorkflowTypeInterface.php @@ -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(); + } diff --git a/core/modules/workflows/tests/modules/workflow_type_test/config/schema/workflow_type_test.schema.yml b/core/modules/workflows/tests/modules/workflow_type_test/config/schema/workflow_type_test.schema.yml index 4f12fdd1f459a51e2b77cfd4a874939923441cd6..84e7a4169eaeb1c233b6210c8df2fe3b7c22e6f9 100644 --- a/core/modules/workflows/tests/modules/workflow_type_test/config/schema/workflow_type_test.schema.yml +++ b/core/modules/workflows/tests/modules/workflow_type_test/config/schema/workflow_type_test.schema.yml @@ -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' diff --git a/core/modules/workflows/tests/modules/workflow_type_test/src/Plugin/WorkflowType/RequiredStateTestType.php b/core/modules/workflows/tests/modules/workflow_type_test/src/Plugin/WorkflowType/RequiredStateTestType.php new file mode 100644 index 0000000000000000000000000000000000000000..493355715a000de1530f72447e1022afff6ab946 --- /dev/null +++ b/core/modules/workflows/tests/modules/workflow_type_test/src/Plugin/WorkflowType/RequiredStateTestType.php @@ -0,0 +1,45 @@ +<?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 []; + } + +} diff --git a/core/modules/workflows/tests/modules/workflow_type_test/src/Plugin/WorkflowType/TestType.php b/core/modules/workflows/tests/modules/workflow_type_test/src/Plugin/WorkflowType/TestType.php index d328d63d9996d2ae21f8872cb378a5ba2e8fb2a4..2ff68a84adca3be50e926883b39eee79343f915d 100644 --- a/core/modules/workflows/tests/modules/workflow_type_test/src/Plugin/WorkflowType/TestType.php +++ b/core/modules/workflows/tests/modules/workflow_type_test/src/Plugin/WorkflowType/TestType.php @@ -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', []); + } + } diff --git a/core/modules/workflows/tests/src/Functional/WorkflowUiTest.php b/core/modules/workflows/tests/src/Functional/WorkflowUiTest.php index 37de109de8ba3245981f305d56283cc003012f26..91c69b73f7fe0b502a2a4d92b225eae2c7343ad9 100644 --- a/core/modules/workflows/tests/src/Functional/WorkflowUiTest.php +++ b/core/modules/workflows/tests/src/Functional/WorkflowUiTest.php @@ -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'); } /** diff --git a/core/modules/workflows/tests/src/Kernel/RequiredStatesTest.php b/core/modules/workflows/tests/src/Kernel/RequiredStatesTest.php new file mode 100644 index 0000000000000000000000000000000000000000..18a2c488265ead9089889ea24222f031d0bbf6d5 --- /dev/null +++ b/core/modules/workflows/tests/src/Kernel/RequiredStatesTest.php @@ -0,0 +1,121 @@ +<?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()); + + // Ensure transitions can be altered. + $workflow + ->addState('cooked', 'Cooked') + ->setTransitionFromStates('rot', ['fresh', 'cooked']) + ->save(); + $this->assertTrue($workflow->hasTransitionFromStateToState('fresh', 'rotten')); + $this->assertTrue($workflow->hasTransitionFromStateToState('cooked', 'rotten')); + + $workflow + ->setTransitionFromStates('rot', ['cooked']) + ->save(); + $this->assertFalse($workflow->hasTransitionFromStateToState('fresh', 'rotten')); + $this->assertTrue($workflow->hasTransitionFromStateToState('cooked', 'rotten')); + + // Ensure the default configuration does not cause ordering issues. + $workflow->addTransition('cook', 'Cook', ['fresh'], 'cooked')->save(); + $this->assertSame([ + 'cooked', + 'fresh', + 'rotten', + ], array_keys($workflow->get('states'))); + $this->assertSame([ + 'cook', + 'rot', + ], array_keys($workflow->get('transitions'))); + + // Ensure that transitions can be deleted. + $workflow->deleteTransition('rot')->save(); + $this->assertFalse($workflow->hasTransition('rot')); + } + +} diff --git a/core/modules/workflows/workflows.routing.yml b/core/modules/workflows/workflows.routing.yml index 377e572202d9684520b1bb3b830558f572922ce3..329ed10c484718c8c2a1d918fba2d9ebc47ad670 100644 --- a/core/modules/workflows/workflows.routing.yml +++ b/core/modules/workflows/workflows.routing.yml @@ -20,7 +20,7 @@ entity.workflow.delete_state_form: _form: '\Drupal\workflows\Form\WorkflowStateDeleteForm' _title: 'Delete state' requirements: - _entity_access: 'workflow.delete-state' + _workflow_state_delete_access: 'true' entity.workflow.add_transition_form: path: '/admin/config/workflow/workflows/manage/{workflow}/add_transition' diff --git a/core/modules/workflows/workflows.services.yml b/core/modules/workflows/workflows.services.yml index 772bab71d2a5adb78fd3f361e11e39637c430b07..7d32420242d57d1b88d554e7f3229a02eeb93321 100644 --- a/core/modules/workflows/workflows.services.yml +++ b/core/modules/workflows/workflows.services.yml @@ -3,4 +3,8 @@ services: class: Drupal\workflows\WorkflowTypeManager parent: default_plugin_manager tags: - - { name: plugin_manager_cache_clear } \ No newline at end of file + - { name: plugin_manager_cache_clear } + workflows.access_check.delete_state: + class: \Drupal\workflows\WorkflowDeleteAccessCheck + tags: + - { name: access_check, applies_to: _workflow_state_delete_access }