Initial Drupal 11 with DDEV setup

This commit is contained in:
gluebox
2025-10-08 11:39:17 -04:00
commit 89ef74b305
25344 changed files with 2599172 additions and 0 deletions

View File

@ -0,0 +1,50 @@
workflows.workflow.*:
type: config_entity
label: 'Workflow'
mapping:
id:
type: machine_name
label: 'ID'
label:
type: required_label
label: 'Label'
type:
type: string
label: 'Workflow type'
constraints:
PluginExists:
manager: plugin.manager.workflows.type
interface: 'Drupal\workflows\WorkflowTypeInterface'
type_settings:
type: workflow.type_settings.[%parent.type]
workflows.state:
type: mapping
mapping:
label:
type: label
label: 'Label'
translation context: 'Workflow state label'
weight:
type: weight
label: 'Weight'
workflows.transition:
type: mapping
mapping:
label:
type: label
label: 'Transition label'
translation context: 'Workflow transition label'
from:
type: sequence
label: 'From state IDs'
sequence:
type: string
label: 'From state ID'
to:
type: string
label: 'To state ID'
weight:
type: weight
label: 'Weight'

View File

@ -0,0 +1,23 @@
---
label: 'Managing content moderation workflows'
top_level: true
related:
- core.content_structure
---
{% set configuring_workflows_topic = render_var(help_topic_link('content_moderation.configuring_workflows')) %}
{% set changing_states_topic = render_var(help_topic_link('content_moderation.changing_states')) %}
<h2>{% trans %}What is a content moderation workflow?{% endtrans %}</h2>
<p>{% trans %}On some sites, new content and content revisions need to be <em>moderated</em>. That is, they need to pass through several <em>states</em> before becoming visible to site visitors. The collection of states and the definition of the transitions between states is known as a <em>workflow</em>. For example, new content might start out in a <em>Draft</em> state, and then might need to pass through several <em>Review</em> states before it becomes <em>Published</em> on the live site.{% endtrans %}</p>
<p>{% trans %}The core software allows you to configure workflows in which each transition has an associated permission that can be granted to a particular role. See {{ configuring_workflows_topic }} for more information.{% endtrans %}</p>
<p>{% trans %}Users with sufficient permissions can change the workflow state of a particular entity. See {{ changing_states_topic }} for more information.{% endtrans %}</p>
<h2>{% trans %}Overview of content moderation workflows{% endtrans %}</h2>
<ul>
<li>{% trans %}The core Content Moderation module allows you to expand on core software's "unpublished" and "published" states for content. It allows you to have a published version that is live, but have a separate working copy that is undergoing review before it is published. This is achieved by using workflows to apply different states and transitions to entities as needed.{% endtrans %}</li>
<li>{% trans %}The core Workflows module allows you to manage workflows with states and transitions.{% endtrans %}</li>
</ul>
<p>{% trans %}See the related topics listed below for specific tasks and background information.{% endtrans %}</p>
<h2>{% trans %}Additional resources{% endtrans %}</h2>
<ul>
<li><a href="https://www.drupal.org/docs/8/core/modules/content-moderation/overview">{% trans %}On-line documentation about Content Moderation{% endtrans %}</a>
</li>
</ul>

View File

@ -0,0 +1,70 @@
<?php
namespace Drupal\workflows\Annotation;
use Drupal\Component\Annotation\Plugin;
/**
* Defines a Workflow type annotation object.
*
* Plugin Namespace: Plugin\WorkflowType
*
* For a working example, see
* \Drupal\content_moderation\Plugin\Workflow\ContentModerate
*
* @see \Drupal\workflows\WorkflowTypeInterface
* @see \Drupal\workflows\WorkflowTypeManager
* @see workflow_type_info_alter()
* @see plugin_api
*
* @Annotation
*/
class WorkflowType extends Plugin {
/**
* The plugin ID.
*
* @var string
*/
public $id;
/**
* The label of the workflow.
*
* @var \Drupal\Core\Annotation\Translation
*
* @ingroup plugin_translatable
*/
public $label = '';
/**
* States required to exist.
*
* Normally supplied by WorkflowType::defaultConfiguration().
*
* @var array
*/
public $required_states = [];
/**
* A list of optional form classes implementing PluginFormInterface.
*
* Forms which will be used for the workflow UI are:
* - 'configure' (\Drupal\workflows\WorkflowTypeInterface::PLUGIN_FORM_KEY)
* - 'state' (\Drupal\workflows\StateInterface::PLUGIN_FORM_KEY)
* - 'transition' (\Drupal\workflows\TransitionInterface::PLUGIN_FORM_KEY)
*
* @var array
*
* @see \Drupal\Core\Plugin\PluginWithFormsInterface
* @see \Drupal\Core\Plugin\PluginFormInterface
* @see \Drupal\workflows\Plugin\WorkflowTypeConfigureFormBase
* @see \Drupal\workflows\Plugin\WorkflowTypeStateFormBase
* @see \Drupal\workflows\Plugin\WorkflowTypeTransitionFormBase
* @see \Drupal\workflows\WorkflowTypeInterface::PLUGIN_FORM_KEY
* @see \Drupal\workflows\StateInterface::PLUGIN_FORM_KEY
* @see \Drupal\workflows\TransitionInterface::PLUGIN_FORM_KEY
*/
public $forms = [];
}

View File

@ -0,0 +1,72 @@
<?php
namespace Drupal\workflows\Attribute;
use Drupal\Component\Plugin\Attribute\Plugin;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Defines a Workflow type attribute object.
*
* Plugin Namespace: Plugin\WorkflowType
*
* For a working example, see
* \Drupal\content_moderation\Plugin\Workflow\ContentModerate
*
* @see \Drupal\workflows\WorkflowTypeInterface
* @see \Drupal\workflows\WorkflowTypeManager
* @see workflow_type_info_alter()
* @see plugin_api
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
class WorkflowType extends Plugin {
/**
* States required to exist.
*
* Normally supplied by WorkflowType::defaultConfiguration().
*/
public array $required_states = [];
/**
* A list of optional form classes implementing PluginFormInterface.
*
* Forms which will be used for the workflow UI are:
* - 'configure' (\Drupal\workflows\WorkflowTypeInterface::PLUGIN_FORM_KEY)
* - 'state' (\Drupal\workflows\StateInterface::PLUGIN_FORM_KEY)
* - 'transition' (\Drupal\workflows\TransitionInterface::PLUGIN_FORM_KEY)
*
* @see \Drupal\Core\Plugin\PluginWithFormsInterface
* @see \Drupal\Core\Plugin\PluginFormInterface
* @see \Drupal\workflows\Plugin\WorkflowTypeConfigureFormBase
* @see \Drupal\workflows\Plugin\WorkflowTypeStateFormBase
* @see \Drupal\workflows\Plugin\WorkflowTypeTransitionFormBase
* @see \Drupal\workflows\WorkflowTypeInterface::PLUGIN_FORM_KEY
* @see \Drupal\workflows\StateInterface::PLUGIN_FORM_KEY
* @see \Drupal\workflows\TransitionInterface::PLUGIN_FORM_KEY
*/
public array $forms = [];
/**
* Constructs an Action attribute.
*
* @param string $id
* The plugin ID.
* @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $label
* The label of the action.
* @param string[] $forms
* A list of optional form classes implementing PluginFormInterface.
* @param string[] $required_states
* States required to exist.
*/
public function __construct(
public readonly string $id,
public readonly ?TranslatableMarkup $label = NULL,
array $forms = [],
array $required_states = [],
) {
$this->forms = $forms;
$this->required_states = $required_states;
}
}

View File

@ -0,0 +1,190 @@
<?php
namespace Drupal\workflows\Entity;
use Drupal\Core\Entity\Attribute\ConfigEntityType;
use Drupal\Core\Entity\Routing\AdminHtmlRouteProvider;
use Drupal\Core\StringTranslation\TranslatableMarkup;
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\Form\WorkflowAddForm;
use Drupal\workflows\Form\WorkflowDeleteForm;
use Drupal\workflows\Form\WorkflowEditForm;
use Drupal\workflows\Form\WorkflowStateAddForm;
use Drupal\workflows\Form\WorkflowStateDeleteForm;
use Drupal\workflows\Form\WorkflowStateEditForm;
use Drupal\workflows\Form\WorkflowTransitionAddForm;
use Drupal\workflows\Form\WorkflowTransitionDeleteForm;
use Drupal\workflows\Form\WorkflowTransitionEditForm;
use Drupal\workflows\WorkflowAccessControlHandler;
use Drupal\workflows\WorkflowInterface;
use Drupal\workflows\WorkflowListBuilder;
/**
* Defines the workflow entity.
*/
#[ConfigEntityType(
id: 'workflow',
label: new TranslatableMarkup('Workflow'),
label_collection: new TranslatableMarkup('Workflows'),
label_singular: new TranslatableMarkup('workflow'),
label_plural: new TranslatableMarkup('workflows'),
config_prefix: 'workflow',
entity_keys: [
'id' => 'id',
'label' => 'label',
'uuid' => 'uuid',
],
handlers: [
'access' => WorkflowAccessControlHandler::class,
'list_builder' => WorkflowListBuilder::class,
'form' => [
'add' => WorkflowAddForm::class,
'edit' => WorkflowEditForm::class,
'delete' => WorkflowDeleteForm::class,
'add-state' => WorkflowStateAddForm::class,
'edit-state' => WorkflowStateEditForm::class,
'delete-state' => WorkflowStateDeleteForm::class,
'add-transition' => WorkflowTransitionAddForm::class,
'edit-transition' => WorkflowTransitionEditForm::class,
'delete-transition' => WorkflowTransitionDeleteForm::class,
],
'route_provider' => ['html' => AdminHtmlRouteProvider::class],
],
links: [
'add-form' => '/admin/config/workflow/workflows/add',
'edit-form' => '/admin/config/workflow/workflows/manage/{workflow}',
'delete-form' => '/admin/config/workflow/workflows/manage/{workflow}/delete',
'add-state-form' => '/admin/config/workflow/workflows/manage/{workflow}/add_state',
'add-transition-form' => '/admin/config/workflow/workflows/manage/{workflow}/add_transition',
'collection' => '/admin/config/workflow/workflows',
],
admin_permission: 'administer workflows',
label_count: [
'singular' => '@count workflow',
'plural' => '@count workflows',
],
config_export: [
'id',
'label',
'type',
'type_settings',
],
)]
class Workflow extends ConfigEntityBase implements WorkflowInterface, EntityWithPluginCollectionInterface {
/**
* The Workflow ID.
*
* @var string
*/
protected $id;
/**
* The workflow label.
*
* @var string
*/
protected $label;
/**
* The workflow type plugin ID.
*
* @var string
*
* @see \Drupal\workflows\WorkflowTypeManager
*/
protected $type;
/**
* The configuration for the workflow type plugin.
*
* @var array
*/
protected $type_settings = [];
/**
* The workflow type plugin collection.
*
* @var \Drupal\Component\Plugin\LazyPluginCollection
*/
protected $pluginCollection;
/**
* {@inheritdoc}
*/
public function preSave(EntityStorageInterface $storage) {
$workflow_type = $this->getTypePlugin();
$missing_states = array_diff($workflow_type->getRequiredStates(), array_keys($this->getTypePlugin()->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}
*/
public function getTypePlugin() {
return $this->getPluginCollection()->get($this->type);
}
/**
* {@inheritdoc}
*/
public function getPluginCollections() {
return ['type_settings' => $this->getPluginCollection()];
}
/**
* Encapsulates the creation of the workflow's plugin collection.
*
* @return \Drupal\Core\Plugin\DefaultSingleLazyPluginCollection
* The workflow's plugin collection.
*/
protected function getPluginCollection() {
if (!$this->pluginCollection && $this->type) {
$this->pluginCollection = new DefaultSingleLazyPluginCollection(\Drupal::service('plugin.manager.workflows.type'), $this->type, $this->type_settings);
}
return $this->pluginCollection;
}
/**
* Loads all workflows of the provided type.
*
* @param string $type
* The workflow type to load all workflows for.
*
* @return static[]
* An array of workflow objects of the provided workflow type, indexed by
* their IDs.
*
* @see \Drupal\workflows\Annotation\WorkflowType
*/
public static function loadMultipleByType($type) {
return self::loadMultiple(\Drupal::entityQuery('workflow')->condition('type', $type)->execute());
}
/**
* {@inheritdoc}
*/
public function status() {
// In order for a workflow to be usable it must have at least one state.
return !empty($this->status) && !empty($this->getTypePlugin()->getStates());
}
/**
* {@inheritdoc}
*/
public function onDependencyRemoval(array $dependencies) {
// Give the parent method and the workflow type plugin a chance to react
// to removed dependencies and report if either of these two made a change.
$parent_changed_entity = parent::onDependencyRemoval($dependencies);
$plugin_changed_entity = $this->getTypePlugin()->onDependencyRemoval($dependencies);
return $plugin_changed_entity || $parent_changed_entity;
}
}

View File

@ -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 {
}

View File

@ -0,0 +1,116 @@
<?php
namespace Drupal\workflows\Form;
use Drupal\Component\Plugin\PluginManagerInterface;
use Drupal\workflows\Entity\Workflow;
use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Form for adding workflows.
*
* @internal
*/
class WorkflowAddForm extends EntityForm {
/**
* The workflow type plugin manager.
*
* @var \Drupal\Component\Plugin\PluginManagerInterface
*/
protected $workflowTypePluginManager;
/**
* WorkflowAddForm constructor.
*
* @param \Drupal\Component\Plugin\PluginManagerInterface $workflow_type_plugin_manager
* The workflow type plugin manager.
*/
public function __construct(PluginManagerInterface $workflow_type_plugin_manager) {
$this->workflowTypePluginManager = $workflow_type_plugin_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('plugin.manager.workflows.type')
);
}
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state) {
$form = parent::form($form, $form_state);
/** @var \Drupal\workflows\WorkflowInterface $workflow */
$workflow = $this->entity;
$form['label'] = [
'#type' => 'textfield',
'#title' => $this->t('Label'),
'#maxlength' => 255,
'#default_value' => $workflow->label(),
'#required' => TRUE,
];
$form['id'] = [
'#type' => 'machine_name',
'#default_value' => $workflow->id(),
'#machine_name' => [
'exists' => [Workflow::class, 'load'],
],
];
$workflow_types = array_column($this->workflowTypePluginManager->getDefinitions(), 'label', 'id');
$form['workflow_type'] = [
'#type' => 'select',
'#title' => $this->t('Workflow type'),
'#required' => TRUE,
'#options' => $workflow_types,
];
return $form;
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
/** @var \Drupal\workflows\WorkflowInterface $workflow */
$workflow = $this->entity;
$return = $workflow->save();
if (empty($workflow->getTypePlugin()->getStates())) {
$this->messenger()->addStatus($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 {
$this->messenger()->addStatus($this->t('Created the %label Workflow.', [
'%label' => $workflow->label(),
]));
$form_state->setRedirectUrl($workflow->toUrl('edit-form'));
}
return $return;
}
/**
* {@inheritdoc}
*/
protected function copyFormValuesToEntity(EntityInterface $entity, array $form, FormStateInterface $form_state) {
// This form can only set the workflow's ID, label and the weights for each
// state.
/** @var \Drupal\workflows\WorkflowInterface $entity */
$values = $form_state->getValues();
$entity->set('label', $values['label']);
$entity->set('id', $values['id']);
$entity->set('type', $values['workflow_type']);
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace Drupal\workflows\Form;
use Drupal\Core\Entity\EntityConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
/**
* Builds the form to delete Workflow entities.
*
* @internal
*/
class WorkflowDeleteForm extends EntityConfirmFormBase {
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
if ($this->entity->getTypePlugin()->workflowHasData($this->entity)) {
$form['#title'] = $this->getQuestion();
$form['description'] = ['#markup' => $this->t('This workflow is in use. You cannot remove this workflow until you have removed all content using it.')];
return $form;
}
return parent::buildForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function getQuestion() {
return $this->t('Are you sure you want to delete %name?', ['%name' => $this->entity->label()]);
}
/**
* {@inheritdoc}
*/
public function getCancelUrl() {
return new Url('entity.workflow.collection');
}
/**
* {@inheritdoc}
*/
public function getConfirmText() {
return $this->t('Delete');
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->entity->delete();
$this->messenger()->addStatus($this->t(
'Workflow %label deleted.',
['%label' => $this->entity->label()]
));
$form_state->setRedirectUrl($this->getCancelUrl());
}
}

View File

@ -0,0 +1,283 @@
<?php
namespace Drupal\workflows\Form;
use Drupal\Core\Form\SubformState;
use Drupal\Core\Plugin\PluginFormFactoryInterface;
use Drupal\workflows\Entity\Workflow;
use Drupal\workflows\State;
use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
use Drupal\workflows\WorkflowTypeInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* The form for editing workflows.
*
* @internal
*/
class WorkflowEditForm extends EntityForm {
/**
* The plugin form factory.
*
* @var \Drupal\Core\Plugin\PluginFormFactoryInterface
*/
protected $pluginFormFactory;
/**
* Creates an instance of WorkflowStateEditForm.
*
* @param \Drupal\Core\Plugin\PluginFormFactoryInterface $pluginFormFactory
* The plugin form factory.
*/
public function __construct(PluginFormFactoryInterface $pluginFormFactory) {
$this->pluginFormFactory = $pluginFormFactory;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('plugin_form.factory')
);
}
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state) {
$form = parent::form($form, $form_state);
/** @var \Drupal\workflows\WorkflowInterface $workflow */
$workflow = $this->entity;
$workflow_type = $workflow->getTypePlugin();
$form['#title'] = $this->t('Edit %label workflow', ['%label' => $workflow->label()]);
$form['label'] = [
'#type' => 'textfield',
'#title' => $this->t('Label'),
'#maxlength' => 255,
'#default_value' => $workflow->label(),
'#required' => TRUE,
];
$form['id'] = [
'#type' => 'machine_name',
'#default_value' => $workflow->id(),
'#machine_name' => [
'exists' => [Workflow::class, 'load'],
],
'#disabled' => TRUE,
];
$header = [
'state' => $this->t('State'),
'weight' => $this->t('Weight'),
'operations' => $this->t('Operations'),
];
$form['states_container'] = [
'#type' => 'details',
'#title' => $this->t('States'),
'#open' => TRUE,
];
$form['states_container']['states'] = [
'#type' => 'table',
'#header' => $header,
'#title' => $this->t('States'),
'#empty' => $this->t('There are no states yet.'),
'#tabledrag' => [
[
'action' => 'order',
'relationship' => 'sibling',
'group' => 'state-weight',
],
],
];
$states = $workflow->getTypePlugin()->getStates();
// Warn the user if there are no states.
if (empty($states)) {
$this->messenger()->addWarning(
$this->t(
'This workflow has no states and will be disabled until there is at least one, <a href=":add-state">add a new state.</a>',
[':add-state' => $workflow->toUrl('add-state-form')->toString()]
)
);
}
$state_weight_delta = round(count($states) / 2);
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()]),
],
];
if ($this->entity->access('delete-state:' . $state->id())) {
$links['delete'] = [
'title' => $this->t('Delete'),
'url' => Url::fromRoute('entity.workflow.delete_state_form', [
'workflow' => $workflow->id(),
'workflow_state' => $state->id(),
]),
];
}
$form['states_container']['states'][$state->id()] = [
'#attributes' => ['class' => ['draggable']],
'state' => ['#markup' => $state->label()],
'#weight' => $state->weight(),
'weight' => [
'#type' => 'weight',
'#title' => $this->t('Weight for @title', ['@title' => $state->label()]),
'#title_display' => 'invisible',
'#default_value' => $state->weight(),
'#attributes' => ['class' => ['state-weight']],
'#delta' => $state_weight_delta,
],
'operations' => [
'#type' => 'operations',
'#links' => $links,
],
];
}
$form['states_container']['state_add'] = [
'#markup' => $workflow->toLink($this->t('Add a new state'), 'add-state-form')->toString(),
];
$header = [
'label' => $this->t('Label'),
'weight' => $this->t('Weight'),
'from' => $this->t('From'),
'to' => $this->t('To'),
'operations' => $this->t('Operations'),
];
$form['transitions_container'] = [
'#type' => 'details',
'#title' => $this->t('Transitions'),
'#open' => TRUE,
];
$form['transitions_container']['transitions'] = [
'#type' => 'table',
'#header' => $header,
'#title' => $this->t('Transitions'),
'#empty' => $this->t('There are no transitions yet.'),
'#tabledrag' => [
[
'action' => 'order',
'relationship' => 'sibling',
'group' => 'transition-weight',
],
],
];
$transitions = $workflow->getTypePlugin()->getTransitions();
$transition_weight_delta = round(count($transitions) / 2);
foreach ($transitions as $transition) {
$links['edit'] = [
'title' => $this->t('Edit'),
'url' => Url::fromRoute('entity.workflow.edit_transition_form', ['workflow' => $workflow->id(), 'workflow_transition' => $transition->id()]),
];
$links['delete'] = [
'title' => $this->t('Delete'),
'url' => Url::fromRoute('entity.workflow.delete_transition_form', ['workflow' => $workflow->id(), 'workflow_transition' => $transition->id()]),
];
$form['transitions_container']['transitions'][$transition->id()] = [
'#attributes' => ['class' => ['draggable']],
'label' => ['#markup' => $transition->label()],
'#weight' => $transition->weight(),
'weight' => [
'#type' => 'weight',
'#title' => $this->t('Weight for @title', ['@title' => $transition->label()]),
'#title_display' => 'invisible',
'#default_value' => $transition->weight(),
'#attributes' => ['class' => ['transition-weight']],
'#delta' => $transition_weight_delta,
],
'from' => [
'#theme' => 'item_list',
'#items' => array_map([State::class, 'labelCallback'], $transition->from()),
'#context' => ['list_style' => 'comma-list'],
],
'to' => ['#markup' => $transition->to()->label()],
'operations' => [
'#type' => 'operations',
'#links' => $links,
],
];
}
$form['transitions_container']['transition_add'] = [
'#markup' => $workflow->toLink($this->t('Add a new transition'), 'add-transition-form')->toString(),
];
if ($workflow_type->hasFormClass(WorkflowTypeInterface::PLUGIN_FORM_KEY)) {
$form['type_settings'] = [
'#tree' => TRUE,
];
$subform_state = SubformState::createForSubform($form['type_settings'], $form, $form_state);
$form['type_settings'] += $this->pluginFormFactory
->createInstance($workflow_type, WorkflowTypeInterface::PLUGIN_FORM_KEY)
->buildConfigurationForm($form['type_settings'], $subform_state);
}
return $form;
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
/** @var \Drupal\workflows\WorkflowInterface $workflow */
$workflow = $this->entity;
$workflow_type = $workflow->getTypePlugin();
if ($workflow_type->hasFormClass(WorkflowTypeInterface::PLUGIN_FORM_KEY)) {
$subform_state = SubformState::createForSubform($form['type_settings'], $form, $form_state);
$this->pluginFormFactory
->createInstance($workflow_type, WorkflowTypeInterface::PLUGIN_FORM_KEY)
->validateConfigurationForm($form['type_settings'], $subform_state);
}
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
/** @var \Drupal\workflows\WorkflowInterface $workflow */
$workflow = $this->entity;
$workflow_type = $workflow->getTypePlugin();
if ($workflow_type->hasFormClass(WorkflowTypeInterface::PLUGIN_FORM_KEY)) {
$subform_state = SubformState::createForSubform($form['type_settings'], $form, $form_state);
$this->pluginFormFactory
->createInstance($workflow_type, WorkflowTypeInterface::PLUGIN_FORM_KEY)
->submitConfigurationForm($form['type_settings'], $subform_state);
}
$workflow->save();
$this->messenger()->addStatus($this->t('Saved the %label Workflow.', ['%label' => $workflow->label()]));
}
/**
* {@inheritdoc}
*/
protected function copyFormValuesToEntity(EntityInterface $entity, array $form, FormStateInterface $form_state) {
// This form can only set the workflow's ID, label and the weights for each
// state.
/** @var \Drupal\workflows\WorkflowInterface $entity */
$values = $form_state->getValues();
$entity->set('label', $values['label']);
$entity->set('id', $values['id']);
foreach ($values['states'] as $state_id => $state_values) {
$entity->getTypePlugin()->setStateWeight($state_id, $state_values['weight']);
}
foreach ($values['transitions'] as $transition_id => $transition_values) {
$entity->getTypePlugin()->setTransitionWeight($transition_id, $transition_values['weight']);
}
}
}

View File

@ -0,0 +1,181 @@
<?php
namespace Drupal\workflows\Form;
use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\SubformState;
use Drupal\Core\Plugin\PluginFormFactoryInterface;
use Drupal\workflows\StateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Entity form variant for adding workflow states.
*
* @internal
*/
class WorkflowStateAddForm extends EntityForm {
/**
* The plugin form factory.
*
* @var \Drupal\Core\Plugin\PluginFormFactoryInterface
*/
protected $pluginFormFactory;
/**
* Creates an instance of WorkflowStateEditForm.
*
* @param \Drupal\Core\Plugin\PluginFormFactoryInterface $pluginFormFactory
* The plugin form factory.
*/
public function __construct(PluginFormFactoryInterface $pluginFormFactory) {
$this->pluginFormFactory = $pluginFormFactory;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('plugin_form.factory')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'workflow_state_add_form';
}
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state) {
$form = parent::form($form, $form_state);
/** @var \Drupal\workflows\WorkflowInterface $workflow */
$workflow = $this->getEntity();
$workflow_type = $workflow->getTypePlugin();
$form['label'] = [
'#type' => 'textfield',
'#title' => $this->t('State label'),
'#maxlength' => 255,
'#default_value' => '',
'#required' => TRUE,
];
$form['id'] = [
'#type' => 'machine_name',
'#machine_name' => [
'exists' => [$this, 'exists'],
],
];
if ($workflow_type->hasFormClass(StateInterface::PLUGIN_FORM_KEY)) {
$form['type_settings'] = [
'#tree' => TRUE,
];
$subform_state = SubformState::createForSubform($form['type_settings'], $form, $form_state);
$form['type_settings'] += $this->pluginFormFactory
->createInstance($workflow_type, StateInterface::PLUGIN_FORM_KEY)
->buildConfigurationForm($form['type_settings'], $subform_state);
}
return $form;
}
/**
* Determines if the workflow state already exists.
*
* @param string $state_id
* The workflow state ID.
*
* @return bool
* TRUE if the workflow state exists, FALSE otherwise.
*/
public function exists($state_id) {
/** @var \Drupal\workflows\WorkflowInterface $original_workflow */
$original_workflow = \Drupal::entityTypeManager()->getStorage('workflow')->loadUnchanged($this->getEntity()->id());
return $original_workflow->getTypePlugin()->hasState($state_id);
}
/**
* Copies top-level form values to entity properties.
*
* This form can only change values for a state, which is part of workflow.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity the current form should operate upon.
* @param array $form
* A nested array of form elements comprising the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*/
protected function copyFormValuesToEntity(EntityInterface $entity, array $form, FormStateInterface $form_state) {
if (!$form_state->isValidationComplete()) {
// Only do something once form validation is complete.
return;
}
/** @var \Drupal\workflows\WorkflowInterface $entity */
$values = $form_state->getValues();
$entity->getTypePlugin()->addState($values['id'], $values['label']);
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
parent::validateForm($form, $form_state);
/** @var \Drupal\workflows\WorkflowTypeInterface $workflow_type */
$workflow = $this->entity;
$workflow_type = $workflow->getTypePlugin();
if ($workflow_type->hasFormClass(StateInterface::PLUGIN_FORM_KEY)) {
$subform_state = SubformState::createForSubform($form['type_settings'], $form, $form_state);
$this->pluginFormFactory
->createInstance($workflow_type, StateInterface::PLUGIN_FORM_KEY)
->validateConfigurationForm($form['type_settings'], $subform_state);
}
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
/** @var \Drupal\workflows\WorkflowInterface $workflow */
$workflow = $this->entity;
$workflow_type = $workflow->getTypePlugin();
$state = $workflow_type->getState($form_state->getValue('id'));
if ($workflow_type->hasFormClass(StateInterface::PLUGIN_FORM_KEY)) {
$subform_state = SubformState::createForSubform($form['type_settings'], $form, $form_state);
$subform_state->set('state', $state);
$this->pluginFormFactory
->createInstance($workflow_type, StateInterface::PLUGIN_FORM_KEY)
->submitConfigurationForm($form['type_settings'], $subform_state);
}
$workflow->save();
$this->messenger()->addStatus($this->t('Created %label state.', [
'%label' => $workflow->getTypePlugin()->getState($form_state->getValue('id'))->label(),
]));
$form_state->setRedirectUrl($workflow->toUrl('edit-form'));
}
/**
* {@inheritdoc}
*/
protected function actions(array $form, FormStateInterface $form_state) {
$actions['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Save'),
'#submit' => ['::submitForm', '::save'],
];
return $actions;
}
}

View File

@ -0,0 +1,109 @@
<?php
namespace Drupal\workflows\Form;
use Drupal\workflows\WorkflowInterface;
use Drupal\Core\Form\ConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Builds the form to delete states from Workflow entities.
*
* @internal
*/
class WorkflowStateDeleteForm extends ConfirmFormBase {
/**
* The workflow entity the state being deleted belongs to.
*
* @var \Drupal\workflows\WorkflowInterface
*/
protected $workflow;
/**
* The state being deleted.
*
* @var string
*/
protected $stateId;
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'workflow_state_delete_form';
}
/**
* {@inheritdoc}
*/
public function getQuestion() {
return $this->t('Are you sure you want to delete %state from %workflow?', ['%state' => $this->workflow->getTypePlugin()->getState($this->stateId)->label(), '%workflow' => $this->workflow->label()]);
}
/**
* {@inheritdoc}
*/
public function getCancelUrl() {
return $this->workflow->toUrl();
}
/**
* {@inheritdoc}
*/
public function getConfirmText() {
return $this->t('Delete');
}
/**
* Form constructor.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param \Drupal\workflows\WorkflowInterface $workflow
* The workflow entity being edited.
* @param string|null $workflow_state
* The workflow state being deleted.
*
* @return array
* The form structure.
*/
public function buildForm(array $form, FormStateInterface $form_state, ?WorkflowInterface $workflow = NULL, $workflow_state = NULL) {
if (!$workflow->getTypePlugin()->hasState($workflow_state)) {
throw new NotFoundHttpException();
}
$this->workflow = $workflow;
$this->stateId = $workflow_state;
if ($this->workflow->getTypePlugin()->workflowStateHasData($this->workflow, $this->workflow->getTypePlugin()->getState($this->stateId))) {
$form['#title'] = $this->getQuestion();
$form['description'] = ['#markup' => $this->t('This workflow state is in use. You cannot remove this workflow state until you have removed all content using it.')];
return $form;
}
return parent::buildForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$workflow_label = $this->workflow->getTypePlugin()->getState($this->stateId)->label();
$this->workflow
->getTypePlugin()
->deleteState($this->stateId);
$this->workflow->save();
$this->messenger()->addStatus($this->t(
'State %label deleted.',
['%label' => $workflow_label]
));
$form_state->setRedirectUrl($this->getCancelUrl());
}
}

View File

@ -0,0 +1,240 @@
<?php
namespace Drupal\workflows\Form;
use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\SubformState;
use Drupal\Core\Plugin\PluginFormFactoryInterface;
use Drupal\Core\Url;
use Drupal\workflows\StateInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Entity form variant for editing workflow states.
*
* @internal
*/
class WorkflowStateEditForm extends EntityForm {
/**
* The ID of the state that is being edited.
*
* @var string
*/
protected $stateId;
/**
* The plugin form factory.
*
* @var \Drupal\Core\Plugin\PluginFormFactoryInterface
*/
protected $pluginFormFactory;
/**
* Creates an instance of WorkflowStateEditForm.
*
* @param \Drupal\Core\Plugin\PluginFormFactoryInterface $pluginFormFactory
* The plugin form factory.
*/
public function __construct(PluginFormFactoryInterface $pluginFormFactory) {
$this->pluginFormFactory = $pluginFormFactory;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('plugin_form.factory')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'workflow_state_edit_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, $workflow_state = NULL) {
$this->stateId = $workflow_state;
return parent::buildForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state) {
$form = parent::form($form, $form_state);
/** @var \Drupal\workflows\WorkflowInterface $workflow */
$workflow = $this->getEntity();
$workflow_type = $workflow->getTypePlugin();
$state = $workflow->getTypePlugin()->getState($this->stateId);
$form['label'] = [
'#type' => 'textfield',
'#title' => $this->t('State label'),
'#maxlength' => 255,
'#default_value' => $state->label(),
'#required' => TRUE,
];
$form['id'] = [
'#type' => 'machine_name',
'#default_value' => $this->stateId,
'#machine_name' => [
'exists' => [$this, 'exists'],
],
'#disabled' => TRUE,
];
// Add additional form fields from the workflow type plugin.
if ($workflow_type->hasFormClass(StateInterface::PLUGIN_FORM_KEY)) {
$form['type_settings'] = [
'#tree' => TRUE,
];
$subform_state = SubformState::createForSubform($form['type_settings'], $form, $form_state);
$subform_state->set('state', $state);
$form['type_settings'] += $this->pluginFormFactory
->createInstance($workflow_type, StateInterface::PLUGIN_FORM_KEY)
->buildConfigurationForm($form['type_settings'], $subform_state);
}
$header = [
'label' => $this->t('Transition'),
'state' => $this->t('To'),
'operations' => $this->t('Operations'),
];
$form['transitions'] = [
'#type' => 'table',
'#header' => $header,
'#empty' => $this->t('There are no transitions to or from this state yet.'),
];
foreach ($state->getTransitions() as $transition) {
$links['edit'] = [
'title' => $this->t('Edit'),
'url' => Url::fromRoute('entity.workflow.edit_transition_form', [
'workflow' => $workflow->id(),
'workflow_transition' => $transition->id(),
]),
];
$links['delete'] = [
'title' => $this->t('Delete'),
'url' => Url::fromRoute('entity.workflow.delete_transition_form', [
'workflow' => $workflow->id(),
'workflow_transition' => $transition->id(),
]),
];
$form['transitions'][$transition->id()] = [
'label' => [
'#markup' => $transition->label(),
],
'state' => [
'#markup' => $transition->to()->label(),
],
'operations' => [
'#type' => 'operations',
'#links' => $links,
],
];
}
return $form;
}
/**
* Copies top-level form values to entity properties.
*
* This form can only change values for a state, which is part of workflow.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity the current form should operate upon.
* @param array $form
* A nested array of form elements comprising the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*/
protected function copyFormValuesToEntity(EntityInterface $entity, array $form, FormStateInterface $form_state) {
if (!$form_state->isValidationComplete()) {
// Only do something once form validation is complete.
return;
}
/** @var \Drupal\workflows\WorkflowInterface $entity */
$values = $form_state->getValues();
$entity->getTypePlugin()->setStateLabel($values['id'], $values['label']);
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
parent::validateForm($form, $form_state);
/** @var \Drupal\workflows\WorkflowTypeInterface $workflow_type */
$workflow = $this->entity;
$workflow_type = $workflow->getTypePlugin();
if ($workflow_type->hasFormClass(StateInterface::PLUGIN_FORM_KEY)) {
$subform_state = SubformState::createForSubform($form['type_settings'], $form, $form_state);
$subform_state->set('state', $workflow_type->getState($this->stateId));
$this->pluginFormFactory
->createInstance($workflow_type, StateInterface::PLUGIN_FORM_KEY)
->validateConfigurationForm($form['type_settings'], $subform_state);
}
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
/** @var \Drupal\workflows\WorkflowInterface $workflow */
$workflow = $this->entity;
$workflow_type = $workflow->getTypePlugin();
if ($workflow_type->hasFormClass(StateInterface::PLUGIN_FORM_KEY)) {
$subform_state = SubformState::createForSubform($form['type_settings'], $form, $form_state);
$subform_state->set('state', $workflow_type->getState($this->stateId));
$this->pluginFormFactory
->createInstance($workflow_type, StateInterface::PLUGIN_FORM_KEY)
->submitConfigurationForm($form['type_settings'], $subform_state);
}
$workflow->save();
$this->messenger()->addStatus($this->t('Saved %label state.', [
'%label' => $workflow->getTypePlugin()->getState($this->stateId)->label(),
]));
$form_state->setRedirectUrl($workflow->toUrl('edit-form'));
}
/**
* {@inheritdoc}
*/
protected function actions(array $form, FormStateInterface $form_state) {
$actions['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Save'),
'#submit' => ['::submitForm', '::save'],
];
$actions['delete'] = [
'#type' => 'link',
'#title' => $this->t('Delete'),
'#access' => $this->entity->access('delete-state:' . $this->stateId),
'#attributes' => [
'class' => ['button', 'button--danger'],
],
'#url' => Url::fromRoute('entity.workflow.delete_state_form', [
'workflow' => $this->entity->id(),
'workflow_state' => $this->stateId,
]),
];
return $actions;
}
}

View File

@ -0,0 +1,209 @@
<?php
namespace Drupal\workflows\Form;
use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\SubformState;
use Drupal\Core\Plugin\PluginFormFactoryInterface;
use Drupal\workflows\State;
use Drupal\workflows\TransitionInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Entity form variant for adding workflow transitions.
*
* @internal
*/
class WorkflowTransitionAddForm extends EntityForm {
/**
* The plugin form factory.
*
* @var \Drupal\Core\Plugin\PluginFormFactoryInterface
*/
protected $pluginFormFactory;
/**
* Creates an instance of WorkflowStateEditForm.
*
* @param \Drupal\Core\Plugin\PluginFormFactoryInterface $pluginFormFactory
* The plugin form factory.
*/
public function __construct(PluginFormFactoryInterface $pluginFormFactory) {
$this->pluginFormFactory = $pluginFormFactory;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('plugin_form.factory')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'workflow_transition_add_form';
}
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state) {
$form = parent::form($form, $form_state);
/** @var \Drupal\workflows\WorkflowInterface $workflow */
$workflow = $this->getEntity();
$workflow_type = $workflow->getTypePlugin();
$form['label'] = [
'#type' => 'textfield',
'#title' => $this->t('Transition label'),
'#maxlength' => 255,
'#default_value' => '',
'#required' => TRUE,
];
$form['id'] = [
'#type' => 'machine_name',
'#machine_name' => [
'exists' => [$this, 'exists'],
],
];
// @todo https://www.drupal.org/node/2830584 Add some ajax to ensure that
// only valid transitions are selectable.
$states = array_map([State::class, 'labelCallback'], $workflow->getTypePlugin()->getStates());
$form['from'] = [
'#type' => 'checkboxes',
'#title' => $this->t('From'),
'#required' => TRUE,
'#default_value' => [],
'#options' => $states,
];
$form['to'] = [
'#type' => 'radios',
'#title' => $this->t('To'),
'#required' => TRUE,
'#options' => $states,
];
// Add additional form fields from the workflow type plugin.
if ($workflow_type->hasFormClass(TransitionInterface::PLUGIN_FORM_KEY)) {
$form['type_settings'] = [
'#tree' => TRUE,
];
$subform_state = SubformState::createForSubform($form['type_settings'], $form, $form_state);
$form['type_settings'] += $this->pluginFormFactory
->createInstance($workflow_type, TransitionInterface::PLUGIN_FORM_KEY)
->buildConfigurationForm($form['type_settings'], $subform_state);
}
return $form;
}
/**
* Determines if the workflow transition already exists.
*
* @param string $transition_id
* The workflow transition ID.
*
* @return bool
* TRUE if the workflow transition exists, FALSE otherwise.
*/
public function exists($transition_id) {
/** @var \Drupal\workflows\WorkflowInterface $original_workflow */
$original_workflow = \Drupal::entityTypeManager()->getStorage('workflow')->loadUnchanged($this->getEntity()->id());
return $original_workflow->getTypePlugin()->hasTransition($transition_id);
}
/**
* Copies top-level form values to entity properties.
*
* This form can only change values for a state, which is part of workflow.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity the current form should operate upon.
* @param array $form
* A nested array of form elements comprising the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*/
protected function copyFormValuesToEntity(EntityInterface $entity, array $form, FormStateInterface $form_state) {
if (!$form_state->isValidationComplete()) {
// Only do something once form validation is complete.
return;
}
/** @var \Drupal\workflows\WorkflowInterface $entity */
$values = $form_state->getValues();
$entity->getTypePlugin()->addTransition($values['id'], $values['label'], array_filter($values['from']), $values['to']);
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
/** @var \Drupal\workflows\WorkflowInterface $workflow */
$workflow = $this->getEntity();
$workflow_type = $workflow->getTypePlugin();
$values = $form_state->getValues();
foreach (array_filter($values['from']) as $from_state_id) {
if ($workflow->getTypePlugin()->hasTransitionFromStateToState($from_state_id, $values['to'])) {
$form_state->setErrorByName('from][' . $from_state_id, $this->t('The transition from %from to %to already exists.', [
'%from' => $workflow->getTypePlugin()->getState($from_state_id)->label(),
'%to' => $workflow->getTypePlugin()->getState($values['to'])->label(),
]));
}
}
if ($workflow_type->hasFormClass(TransitionInterface::PLUGIN_FORM_KEY)) {
$subform_state = SubformState::createForSubform($form['type_settings'], $form, $form_state);
$this->pluginFormFactory
->createInstance($workflow_type, TransitionInterface::PLUGIN_FORM_KEY)
->validateConfigurationForm($form['type_settings'], $subform_state);
}
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
/** @var \Drupal\workflows\WorkflowInterface $workflow */
$workflow = $this->entity;
$workflow_type = $workflow->getTypePlugin();
$transition = $workflow_type->getTransition($form_state->getValue('id'));
if ($workflow_type->hasFormClass(TransitionInterface::PLUGIN_FORM_KEY)) {
$subform_state = SubformState::createForSubform($form['type_settings'], $form, $form_state);
$subform_state->set('transition', $transition);
$this->pluginFormFactory
->createInstance($workflow_type, TransitionInterface::PLUGIN_FORM_KEY)
->submitConfigurationForm($form['type_settings'], $subform_state);
}
$workflow->save();
$this->messenger()->addStatus($this->t('Created %label transition.', [
'%label' => $form_state->getValue('label'),
]));
$form_state->setRedirectUrl($workflow->toUrl('edit-form'));
}
/**
* {@inheritdoc}
*/
protected function actions(array $form, FormStateInterface $form_state) {
$actions['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Save'),
'#submit' => ['::submitForm', '::save'],
];
return $actions;
}
}

View File

@ -0,0 +1,105 @@
<?php
namespace Drupal\workflows\Form;
use Drupal\workflows\WorkflowInterface;
use Drupal\Core\Form\ConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Builds the form to delete transitions from Workflow entities.
*
* @internal
*/
class WorkflowTransitionDeleteForm extends ConfirmFormBase {
/**
* The workflow entity the transition being deleted belongs to.
*
* @var \Drupal\workflows\WorkflowInterface
*/
protected $workflow;
/**
* The workflow transition being deleted.
*
* @var \Drupal\workflows\TransitionInterface
*/
protected $transition;
/**
* The transition being deleted.
*
* @var string
*/
protected $transitionId;
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'workflow_transition_delete_form';
}
/**
* {@inheritdoc}
*/
public function getQuestion() {
return $this->t('Are you sure you want to delete %transition from %workflow?', ['%transition' => $this->transition->label(), '%workflow' => $this->workflow->label()]);
}
/**
* {@inheritdoc}
*/
public function getCancelUrl() {
return $this->workflow->toUrl();
}
/**
* {@inheritdoc}
*/
public function getConfirmText() {
return $this->t('Delete');
}
/**
* Form constructor.
*
* @param array $form
* An associative array containing the structure of the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param \Drupal\workflows\WorkflowInterface $workflow
* The workflow entity being edited.
* @param string|null $workflow_transition
* The workflow transition being deleted.
*
* @return array
* The form structure.
*/
public function buildForm(array $form, FormStateInterface $form_state, ?WorkflowInterface $workflow = NULL, $workflow_transition = NULL) {
try {
$this->transition = $workflow->getTypePlugin()->getTransition($workflow_transition);
}
catch (\InvalidArgumentException) {
throw new NotFoundHttpException();
}
$this->workflow = $workflow;
return parent::buildForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->workflow
->getTypePlugin()
->deleteTransition($this->transition->id());
$this->workflow->save();
$this->messenger()->addStatus($this->t('%transition transition deleted.', ['%transition' => $this->transition->label()]));
$form_state->setRedirectUrl($this->getCancelUrl());
}
}

View File

@ -0,0 +1,234 @@
<?php
namespace Drupal\workflows\Form;
use Drupal\Core\Entity\EntityForm;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\SubformState;
use Drupal\Core\Plugin\PluginFormFactoryInterface;
use Drupal\Core\Url;
use Drupal\workflows\State;
use Drupal\workflows\TransitionInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Entity form variant for editing workflow transitions.
*
* @internal
*/
class WorkflowTransitionEditForm extends EntityForm {
/**
* The ID of the transition that is being edited.
*
* @var string
*/
protected $transitionId;
/**
* The plugin form factory.
*
* @var \Drupal\Core\Plugin\PluginFormFactoryInterface
*/
protected $pluginFormFactory;
/**
* Creates an instance of WorkflowStateEditForm.
*
* @param \Drupal\Core\Plugin\PluginFormFactoryInterface $pluginFormFactory
* The plugin form factory.
*/
public function __construct(PluginFormFactoryInterface $pluginFormFactory) {
$this->pluginFormFactory = $pluginFormFactory;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('plugin_form.factory')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'workflow_transition_edit_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state, $workflow_transition = NULL) {
$this->transitionId = $workflow_transition;
return parent::buildForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
public function form(array $form, FormStateInterface $form_state) {
$form = parent::form($form, $form_state);
/** @var \Drupal\workflows\WorkflowInterface $workflow */
$workflow = $this->getEntity();
$workflow_type = $workflow->getTypePlugin();
$transition = $workflow->getTypePlugin()->getTransition($this->transitionId);
$form['label'] = [
'#type' => 'textfield',
'#title' => $this->t('Transition label'),
'#maxlength' => 255,
'#default_value' => $transition->label(),
'#required' => TRUE,
];
$form['id'] = [
'#type' => 'value',
'#value' => $this->transitionId,
];
// @todo https://www.drupal.org/node/2830584 Add some ajax to ensure that
// only valid transitions are selectable.
$states = array_map([State::class, 'labelCallback'], $workflow->getTypePlugin()->getStates());
$form['from'] = [
'#type' => 'checkboxes',
'#title' => $this->t('From'),
'#required' => TRUE,
'#default_value' => array_keys($transition->from()),
'#options' => $states,
];
$form['to'] = [
'#type' => 'radios',
'#title' => $this->t('To'),
'#required' => TRUE,
'#default_value' => $transition->to()->id(),
'#options' => $states,
'#disabled' => TRUE,
];
// Add additional form fields from the workflow type plugin.
if ($workflow_type->hasFormClass(TransitionInterface::PLUGIN_FORM_KEY)) {
$form['type_settings'] = [
'#tree' => TRUE,
];
$subform_state = SubformState::createForSubform($form['type_settings'], $form, $form_state);
$subform_state->set('transition', $transition);
$form['type_settings'] += $this->pluginFormFactory
->createInstance($workflow_type, TransitionInterface::PLUGIN_FORM_KEY)
->buildConfigurationForm($form['type_settings'], $subform_state);
}
return $form;
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
/** @var \Drupal\workflows\WorkflowInterface $workflow */
$workflow = $this->getEntity();
$workflow_type = $workflow->getTypePlugin();
$transition = $workflow_type->getTransition($this->transitionId);
$values = $form_state->getValues();
foreach (array_filter($values['from']) as $from_state_id) {
if ($workflow_type->hasTransitionFromStateToState($from_state_id, $values['to'])) {
$existing_transition = $workflow_type->getTransitionFromStateToState($from_state_id, $values['to']);
if ($existing_transition->id() !== $values['id']) {
$form_state->setErrorByName('from][' . $from_state_id, $this->t('The transition from %from to %to already exists.', [
'%from' => $workflow->getTypePlugin()->getState($from_state_id)->label(),
'%to' => $workflow->getTypePlugin()->getState($values['to'])->label(),
]));
}
}
}
if ($workflow_type->hasFormClass(TransitionInterface::PLUGIN_FORM_KEY)) {
$subform_state = SubformState::createForSubform($form['type_settings'], $form, $form_state);
$subform_state->set('transition', $transition);
$this->pluginFormFactory
->createInstance($workflow_type, TransitionInterface::PLUGIN_FORM_KEY)
->validateConfigurationForm($form['type_settings'], $subform_state);
}
}
/**
* Copies top-level form values to entity properties.
*
* This form can only change values for a state, which is part of workflow.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity the current form should operate upon.
* @param array $form
* A nested array of form elements comprising the form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
*/
protected function copyFormValuesToEntity(EntityInterface $entity, array $form, FormStateInterface $form_state) {
if (!$form_state->isValidationComplete()) {
// Only do something once form validation is complete.
return;
}
/** @var \Drupal\workflows\WorkflowInterface $entity */
$values = $form_state->getValues();
$form_state->set('created_transition', FALSE);
$entity->getTypePlugin()->setTransitionLabel($values['id'], $values['label']);
$entity->getTypePlugin()->setTransitionFromStates($values['id'], array_filter($values['from']));
}
/**
* {@inheritdoc}
*/
public function save(array $form, FormStateInterface $form_state) {
/** @var \Drupal\workflows\WorkflowInterface $workflow */
$workflow = $this->entity;
$workflow_type = $workflow->getTypePlugin();
$transition = $workflow_type->getTransition($this->transitionId);
if ($workflow_type->hasFormClass(TransitionInterface::PLUGIN_FORM_KEY)) {
$subform_state = SubformState::createForSubform($form['type_settings'], $form, $form_state);
$subform_state->set('transition', $transition);
$this->pluginFormFactory
->createInstance($workflow_type, TransitionInterface::PLUGIN_FORM_KEY)
->submitConfigurationForm($form['type_settings'], $subform_state);
}
$workflow->save();
$this->messenger()->addStatus($this->t('Saved %label transition.', [
'%label' => $workflow->getTypePlugin()->getTransition($this->transitionId)->label(),
]));
$form_state->setRedirectUrl($workflow->toUrl('edit-form'));
}
/**
* {@inheritdoc}
*/
protected function actions(array $form, FormStateInterface $form_state) {
$actions['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Save'),
'#submit' => ['::submitForm', '::save'],
];
$actions['delete'] = [
'#type' => 'link',
'#title' => $this->t('Delete'),
// Deleting a transition is editing a workflow.
'#access' => $this->entity->access('edit'),
'#attributes' => [
'class' => ['button', 'button--danger'],
],
'#url' => Url::fromRoute('entity.workflow.delete_transition_form', [
'workflow' => $this->entity->id(),
'workflow_transition' => $this->transitionId,
]),
];
return $actions;
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace Drupal\workflows\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for workflows.
*/
class WorkflowsHooks {
use StringTranslationTrait;
/**
* Implements hook_help().
*/
#[Hook('help')]
public function help($route_name, RouteMatchInterface $route_match): ?string {
switch ($route_name) {
case 'help.page.workflows':
$content_moderation_url = NULL;
if (\Drupal::moduleHandler()->moduleExists('content_moderation')) {
$content_moderation_url = Url::fromRoute('help.page', ['name' => 'content_moderation'])->toString();
}
$output = '<h2>' . $this->t('About') . '</h2>';
if ($content_moderation_url) {
$output .= '<p>' . $this->t('The Workflows module provides an API and an interface to create workflows with transitions between different states (for example publication or user status). These have to be provided by other modules such as the <a href=":moderation">Content Moderation module</a>. For more information, see the <a href=":workflow">online documentation for the Workflows module</a>.', [
':moderation' => $content_moderation_url,
':workflow' => 'https://www.drupal.org/documentation/modules/workflows',
]) . '</p>';
}
else {
$output .= '<p>' . $this->t('The Workflows module provides an API and an interface to create workflows with transitions between different states (for example publication or user status). These have to be provided by other modules such as the Content Moderation module. For more information, see the <a href=":workflow">online documentation for the Workflows module</a>.', [':workflow' => 'https://www.drupal.org/documentation/modules/workflows']) . '</p>';
}
$output .= '<h3>' . $this->t('Uses') . '</h3>';
$output .= '<dl>';
$output .= '<dt>' . $this->t('Adding workflows') . '</dt>';
if ($content_moderation_url) {
$output .= '<dd>' . $this->t('You can <em>only</em> add workflows on the <a href=":workflows">Workflows page</a>, after you have installed a module that leverages the API such as the <a href=":moderation">Content Moderation module</a>.', [
':moderation' => $content_moderation_url,
':workflows' => Url::fromRoute('entity.workflow.collection')->toString(),
]) . '</dd>';
}
else {
$output .= '<dd>' . $this->t('You can <em>only</em> add workflows on the <a href=":workflows">Workflows page</a>, after you have installed a module that leverages the API such as the Content Moderation module.', [':workflow' => 'https://www.drupal.org/documentation/modules/workflows']) . '</dd>';
}
$output .= '<dt>' . $this->t('Adding states') . '<dt>';
$output .= '<dd>' . $this->t('A workflow requires at least two states. States can be added when you add or edit a workflow on the <a href=":workflows">Workflows page</a>.', [
':workflows' => Url::fromRoute('entity.workflow.collection')->toString(),
]) . '</dd>';
$output .= '<dt>' . $this->t('Adding transitions') . '</dt>';
$output .= '<dd>' . $this->t('A transition defines in which state an item can be save as next. It has one destination state, but can have several states <em>from</em> which the transition can be applied. Transitions can be added when you add or edit a workflow on the <a href=":workflows">Workflows page</a>.', [
':workflows' => Url::fromRoute('entity.workflow.collection')->toString(),
]) . '</dd>';
$output .= '<dt>' . $this->t('Configuring workflows further') . '</dt>';
$output .= '<dd>' . $this->t('Depending on the installed workflow type, additional configuration can be available in the edit form of a workflow.') . '</dd>';
$output .= '<dl>';
return $output;
}
return NULL;
}
}

View File

@ -0,0 +1,464 @@
<?php
namespace Drupal\workflows\Plugin;
use Drupal\Component\Plugin\PluginBase;
use Drupal\Core\Plugin\PluginWithFormsTrait;
use Drupal\workflows\State;
use Drupal\workflows\StateInterface;
use Drupal\workflows\Transition;
use Drupal\workflows\TransitionInterface;
use Drupal\workflows\WorkflowInterface;
use Drupal\workflows\WorkflowTypeInterface;
/**
* A base class for Workflow type plugins.
*
* @see \Drupal\workflows\Annotation\WorkflowType
*/
abstract class WorkflowTypeBase extends PluginBase implements WorkflowTypeInterface {
use PluginWithFormsTrait;
/**
* A regex for matching a valid state/transition machine name.
*/
const VALID_ID_REGEX = '/[^a-z0-9_]+/';
/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->setConfiguration($configuration);
}
/**
* {@inheritdoc}
*/
public function label() {
$definition = $this->getPluginDefinition();
// The label can be an object.
// @see \Drupal\Core\StringTranslation\TranslatableMarkup
return $definition['label'];
}
/**
* {@inheritdoc}
*/
public function workflowHasData(WorkflowInterface $workflow) {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function workflowStateHasData(WorkflowInterface $workflow, StateInterface $state) {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function getConfiguration() {
return $this->configuration;
}
/**
* {@inheritdoc}
*/
public function setConfiguration(array $configuration) {
$this->configuration = $configuration + $this->defaultConfiguration();
}
/**
* {@inheritdoc}
*/
public function getRequiredStates() {
return $this->getPluginDefinition()['required_states'];
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'states' => [],
'transitions' => [],
];
}
/**
* {@inheritdoc}
*/
public function calculateDependencies() {
return [];
}
/**
* {@inheritdoc}
*/
public function onDependencyRemoval(array $dependencies) {
return FALSE;
}
/**
* {@inheritdoc}
*/
public function getInitialState() {
$ordered_states = $this->getStates();
return reset($ordered_states);
}
/**
* {@inheritdoc}
*/
public function addState($state_id, $label) {
if ($this->hasState($state_id)) {
throw new \InvalidArgumentException("The state '$state_id' already exists in workflow.");
}
if (preg_match(static::VALID_ID_REGEX, $state_id)) {
throw new \InvalidArgumentException("The state ID '$state_id' must contain only lowercase letters, numbers, and underscores");
}
$this->configuration['states'][$state_id] = [
'label' => $label,
'weight' => $this->getNextWeight($this->configuration['states']),
];
ksort($this->configuration['states']);
return $this;
}
/**
* {@inheritdoc}
*/
public function hasState($state_id) {
return isset($this->configuration['states'][$state_id]);
}
/**
* {@inheritdoc}
*/
public function getStates($state_ids = NULL) {
if ($state_ids === NULL) {
$state_ids = array_keys($this->configuration['states']);
}
/** @var \Drupal\workflows\StateInterface[] $states */
$states = array_combine($state_ids, array_map([$this, 'getState'], $state_ids));
return static::labelWeightMultisort($states);
}
/**
* {@inheritdoc}
*/
public function getState($state_id) {
if (!isset($this->configuration['states'][$state_id])) {
throw new \InvalidArgumentException("The state '$state_id' does not exist in workflow.");
}
return new State(
$this,
$state_id,
$this->configuration['states'][$state_id]['label'],
$this->configuration['states'][$state_id]['weight']
);
}
/**
* {@inheritdoc}
*/
public function setStateLabel($state_id, $label) {
if (!$this->hasState($state_id)) {
throw new \InvalidArgumentException("The state '$state_id' does not exist in workflow.");
}
$this->configuration['states'][$state_id]['label'] = $label;
return $this;
}
/**
* {@inheritdoc}
*/
public function setStateWeight($state_id, $weight) {
if (!$this->hasState($state_id)) {
throw new \InvalidArgumentException("The state '$state_id' does not exist in workflow.");
}
if (!is_numeric($weight)) {
$label = $this->getState($state_id)->label();
throw new \InvalidArgumentException("The weight '$weight' must be numeric for state '$label'.");
}
$this->configuration['states'][$state_id]['weight'] = $weight;
return $this;
}
/**
* {@inheritdoc}
*/
public function deleteState($state_id) {
if (!$this->hasState($state_id)) {
throw new \InvalidArgumentException("The state '$state_id' does not exist in workflow.");
}
if (count($this->configuration['states']) === 1) {
throw new \InvalidArgumentException("The state '$state_id' can not be deleted from workflow as it is the only state.");
}
foreach ($this->configuration['transitions'] as $transition_id => $transition) {
if ($transition['to'] === $state_id) {
$this->deleteTransition($transition_id);
continue;
}
$from_key = array_search($state_id, $transition['from'], TRUE);
if ($from_key !== FALSE) {
// Remove state from the from array.
unset($transition['from'][$from_key]);
if (empty($transition['from'])) {
// There are no more 'from' entries, remove the transition.
$this->deleteTransition($transition_id);
continue;
}
// We changed the from state, update the transition.
$this->setTransitionFromStates($transition_id, $transition['from']);
}
}
unset($this->configuration['states'][$state_id]);
return $this;
}
/**
* {@inheritdoc}
*/
public function addTransition($transition_id, $label, array $from_state_ids, $to_state_id) {
if ($this->hasTransition($transition_id)) {
throw new \InvalidArgumentException("The transition '$transition_id' already exists in workflow.");
}
if (preg_match(static::VALID_ID_REGEX, $transition_id)) {
throw new \InvalidArgumentException("The transition ID '$transition_id' must contain only lowercase letters, numbers, and underscores.");
}
if (!$this->hasState($to_state_id)) {
throw new \InvalidArgumentException("The state '$to_state_id' does not exist in workflow.");
}
$this->configuration['transitions'][$transition_id] = [
'label' => $label,
'from' => [],
'to' => $to_state_id,
// Always add to the end.
'weight' => $this->getNextWeight($this->configuration['transitions']),
];
try {
$this->setTransitionFromStates($transition_id, $from_state_ids);
}
catch (\InvalidArgumentException $e) {
unset($this->configuration['transitions'][$transition_id]);
throw $e;
}
ksort($this->configuration['transitions']);
return $this;
}
/**
* {@inheritdoc}
*/
public function getTransitions(?array $transition_ids = NULL) {
if ($transition_ids === NULL) {
$transition_ids = array_keys($this->configuration['transitions']);
}
/** @var \Drupal\workflows\TransitionInterface[] $transitions */
$transitions = array_combine($transition_ids, array_map([$this, 'getTransition'], $transition_ids));
return static::labelWeightMultisort($transitions);
}
/**
* Sort states or transitions by weight, label, and key.
*
* @param \Drupal\workflows\StateInterface[]|\Drupal\workflows\TransitionInterface[] $objects
* An array of state or transition objects to multi-sort, keyed by the
* state or transition ID.
*
* @return \Drupal\workflows\StateInterface[]|\Drupal\workflows\TransitionInterface[]
* An array of sorted transitions or states, keyed by the state or
* transition ID.
*/
protected static function labelWeightMultisort($objects) {
if (count($objects) > 1) {
// Separate weights, labels, and keys into arrays.
$weights = $labels = [];
$keys = array_keys($objects);
foreach ($objects as $id => $object) {
$weights[$id] = $object->weight();
$labels[$id] = $object->label();
}
// Sort weights, labels, and keys in the same order as each other.
array_multisort(
// Use the numerical weight as the primary sort.
$weights, SORT_NUMERIC, SORT_ASC,
// When objects have the same weight, sort them alphabetically by label.
$labels, SORT_NATURAL, SORT_ASC,
// Ensure that the keys (the object IDs) are sorted in the same order as
// the weights.
$keys
);
// Combine keys and weights to make sure the weights are keyed with the
// correct keys.
$weights = array_combine($keys, $weights);
// Return the objects sorted by weight.
return array_replace($weights, $objects);
}
return $objects;
}
/**
* {@inheritdoc}
*/
public function getTransition($transition_id) {
if (!$this->hasTransition($transition_id)) {
throw new \InvalidArgumentException("The transition '$transition_id' does not exist in workflow.");
}
return new Transition(
$this,
$transition_id,
$this->configuration['transitions'][$transition_id]['label'],
$this->configuration['transitions'][$transition_id]['from'],
$this->configuration['transitions'][$transition_id]['to'],
$this->configuration['transitions'][$transition_id]['weight']
);
}
/**
* {@inheritdoc}
*/
public function hasTransition($transition_id) {
return isset($this->configuration['transitions'][$transition_id]);
}
/**
* {@inheritdoc}
*/
public function getTransitionsForState($state_id, $direction = TransitionInterface::DIRECTION_FROM) {
$transition_ids = array_keys(array_filter($this->configuration['transitions'], function ($transition) use ($state_id, $direction) {
return in_array($state_id, (array) $transition[$direction], TRUE);
}));
return $this->getTransitions($transition_ids);
}
/**
* {@inheritdoc}
*/
public function getTransitionFromStateToState($from_state_id, $to_state_id) {
$transition_id = $this->getTransitionIdFromStateToState($from_state_id, $to_state_id);
if (empty($transition_id)) {
throw new \InvalidArgumentException("The transition from '$from_state_id' to '$to_state_id' does not exist in workflow.");
}
return $this->getTransition($transition_id);
}
/**
* {@inheritdoc}
*/
public function hasTransitionFromStateToState($from_state_id, $to_state_id) {
return $this->getTransitionIdFromStateToState($from_state_id, $to_state_id) !== NULL;
}
/**
* Gets the transition ID from state to state.
*
* @param string $from_state_id
* The state ID to transition from.
* @param string $to_state_id
* The state ID to transition to.
*
* @return string|null
* The transition ID, or NULL if no transition exists.
*/
protected function getTransitionIdFromStateToState($from_state_id, $to_state_id) {
foreach ($this->configuration['transitions'] as $transition_id => $transition) {
if (in_array($from_state_id, $transition['from'], TRUE) && $transition['to'] === $to_state_id) {
return $transition_id;
}
}
return NULL;
}
/**
* {@inheritdoc}
*/
public function setTransitionLabel($transition_id, $label) {
if (!$this->hasTransition($transition_id)) {
throw new \InvalidArgumentException("The transition '$transition_id' does not exist in workflow.");
}
$this->configuration['transitions'][$transition_id]['label'] = $label;
return $this;
}
/**
* {@inheritdoc}
*/
public function setTransitionWeight($transition_id, $weight) {
if (!$this->hasTransition($transition_id)) {
throw new \InvalidArgumentException("The transition '$transition_id' does not exist in workflow.");
}
if (!is_numeric($weight)) {
$label = $this->getTransition($transition_id)->label();
throw new \InvalidArgumentException("The weight '$weight' must be numeric for transition '$label'.");
}
$this->configuration['transitions'][$transition_id]['weight'] = $weight;
return $this;
}
/**
* {@inheritdoc}
*/
public function setTransitionFromStates($transition_id, array $from_state_ids) {
if (!$this->hasTransition($transition_id)) {
throw new \InvalidArgumentException("The transition '$transition_id' does not exist in workflow.");
}
// Ensure that the states exist.
foreach ($from_state_ids as $from_state_id) {
if (!$this->hasState($from_state_id)) {
throw new \InvalidArgumentException("The state '$from_state_id' does not exist in workflow.");
}
if ($this->hasTransitionFromStateToState($from_state_id, $this->configuration['transitions'][$transition_id]['to'])) {
$existing_transition_id = $this->getTransitionIdFromStateToState($from_state_id, $this->configuration['transitions'][$transition_id]['to']);
if ($transition_id !== $existing_transition_id) {
throw new \InvalidArgumentException("The '$existing_transition_id' transition already allows '$from_state_id' to '{$this->configuration['transitions'][$transition_id]['to']}' transitions in workflow.");
}
}
}
// Preserve the order of the state IDs in the from value and don't save any
// keys.
$from_state_ids = array_values($from_state_ids);
sort($from_state_ids);
$this->configuration['transitions'][$transition_id]['from'] = $from_state_ids;
return $this;
}
/**
* {@inheritdoc}
*/
public function deleteTransition($transition_id) {
if (!$this->hasTransition($transition_id)) {
throw new \InvalidArgumentException("The transition '$transition_id' does not exist in workflow.");
}
unset($this->configuration['transitions'][$transition_id]);
return $this;
}
/**
* Gets the weight for a new state or transition.
*
* @param array $items
* An array of states or transitions information where each item has a
* 'weight' key with a numeric value.
*
* @return int
* The weight for a new item in the array so that it has the highest weight.
*/
protected function getNextWeight(array $items) {
return array_reduce($items, function ($carry, $item) {
return max($carry, $item['weight'] + 1);
}, 0);
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace Drupal\workflows\Plugin;
use Drupal\Component\Plugin\PluginAwareInterface;
use Drupal\Component\Plugin\PluginInspectionInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\PluginFormInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* A base class for workflow type configuration forms.
*/
abstract class WorkflowTypeConfigureFormBase implements PluginFormInterface, PluginAwareInterface {
use StringTranslationTrait;
/**
* The workflow type.
*
* @var \Drupal\workflows\WorkflowTypeInterface
*/
protected $workflowType;
/**
* {@inheritdoc}
*/
public function setPlugin(PluginInspectionInterface $plugin) {
$this->workflowType = $plugin;
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace Drupal\workflows\Plugin;
use Drupal\Component\Plugin\PluginAwareInterface;
use Drupal\Component\Plugin\PluginInspectionInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\PluginFormInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* A base class for workflow type state forms.
*/
abstract class WorkflowTypeStateFormBase implements PluginFormInterface, PluginAwareInterface {
use StringTranslationTrait;
/**
* The workflow type.
*
* @var \Drupal\workflows\WorkflowTypeInterface
*/
protected $workflowType;
/**
* {@inheritdoc}
*/
public function setPlugin(PluginInspectionInterface $plugin) {
$this->workflowType = $plugin;
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$values = $form_state->getValues();
$state = $form_state->get('state');
$configuration = $this->workflowType->getConfiguration();
$configuration['states'][$state->id()] = $values + $configuration['states'][$state->id()];
$this->workflowType->setConfiguration($configuration);
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace Drupal\workflows\Plugin;
use Drupal\Component\Plugin\PluginAwareInterface;
use Drupal\Component\Plugin\PluginInspectionInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\PluginFormInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* A base class for workflow type transition forms.
*/
abstract class WorkflowTypeTransitionFormBase implements PluginFormInterface, PluginAwareInterface {
use StringTranslationTrait;
/**
* The workflow type.
*
* @var \Drupal\workflows\WorkflowTypeInterface
*/
protected $workflowType;
/**
* {@inheritdoc}
*/
public function setPlugin(PluginInspectionInterface $plugin) {
$this->workflowType = $plugin;
}
/**
* {@inheritdoc}
*/
public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$values = $form_state->getValues();
$transition = $form_state->get('transition');
$configuration = $this->workflowType->getConfiguration();
$configuration['transitions'][$transition->id()] = $values + $configuration['transitions'][$transition->id()];
$this->workflowType->setConfiguration($configuration);
}
}

View File

@ -0,0 +1,115 @@
<?php
namespace Drupal\workflows;
/**
* A value object representing a workflow state.
*/
class State implements StateInterface {
/**
* The workflow the state is attached to.
*
* @var \Drupal\workflows\WorkflowTypeInterface
*/
protected $workflow;
/**
* The state's ID.
*
* @var string
*/
protected $id;
/**
* The state's label.
*
* @var string
*/
protected $label;
/**
* The state's weight.
*
* @var int
*/
protected $weight;
/**
* State constructor.
*
* @param \Drupal\workflows\WorkflowTypeInterface $workflow
* The workflow the state is attached to.
* @param string $id
* The state's ID.
* @param string $label
* The state's label.
* @param int $weight
* The state's weight.
*/
public function __construct(WorkflowTypeInterface $workflow, $id, $label, $weight = 0) {
$this->workflow = $workflow;
$this->id = $id;
$this->label = $label;
$this->weight = $weight;
}
/**
* {@inheritdoc}
*/
public function id() {
return $this->id;
}
/**
* {@inheritdoc}
*/
public function label() {
return $this->label;
}
/**
* {@inheritdoc}
*/
public function weight() {
return $this->weight;
}
/**
* {@inheritdoc}
*/
public function canTransitionTo($to_state_id) {
return $this->workflow->hasTransitionFromStateToState($this->id, $to_state_id);
}
/**
* {@inheritdoc}
*/
public function getTransitionTo($to_state_id) {
if (!$this->canTransitionTo($to_state_id)) {
throw new \InvalidArgumentException("Can not transition to '$to_state_id' state");
}
return $this->workflow->getTransitionFromStateToState($this->id(), $to_state_id);
}
/**
* {@inheritdoc}
*/
public function getTransitions() {
return $this->workflow->getTransitionsForState($this->id);
}
/**
* Helper method to convert a State value object to a label.
*
* @param \Drupal\workflows\StateInterface $state
* The state.
*
* @return string
* The label of the state.
*/
public static function labelCallback(StateInterface $state) {
return $state->label();
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace Drupal\workflows;
/**
* An interface for state value objects.
*
* @internal
* The StateInterface should only be used by Workflows and Content Moderation.
* @todo Revisit the need for this in https://www.drupal.org/node/2902309.
*/
interface StateInterface {
/**
* The key of the state plugin form.
*/
const PLUGIN_FORM_KEY = 'state';
/**
* Gets the state's ID.
*
* @return string
* The state's ID.
*/
public function id();
/**
* Gets the state's label.
*
* @return string
* The state's label.
*/
public function label();
/**
* Gets the state's weight.
*
* @return int
* The state's weight.
*/
public function weight();
/**
* Determines if the state can transition to the provided state ID.
*
* @param string $to_state_id
* The state to transition to.
*
* @return bool
* TRUE if the state can transition to the provided state ID. FALSE, if not.
*/
public function canTransitionTo($to_state_id);
/**
* Gets the Transition object for the provided state ID.
*
* @param string $to_state_id
* The state to transition to.
*
* @return \Drupal\workflows\TransitionInterface
* The Transition object for the provided state ID.
*
* @throws \InvalidArgumentException
* Exception thrown when the provided state ID can not be transitioned to.
*/
public function getTransitionTo($to_state_id);
/**
* Gets all the possible transition objects for the state.
*
* @return \Drupal\workflows\TransitionInterface[]
* All the possible transition objects for the state.
*/
public function getTransitions();
}

View File

@ -0,0 +1,112 @@
<?php
namespace Drupal\workflows;
/**
* A transition value object that describes the transition between states.
*/
class Transition implements TransitionInterface {
/**
* The workflow that this transition is attached to.
*
* @var \Drupal\workflows\WorkflowTypeInterface
*/
protected $workflow;
/**
* The transition's ID.
*
* @var string
*/
protected $id;
/**
* The transition's label.
*
* @var string
*/
protected $label;
/**
* The transition's from state IDs.
*
* @var string[]
*/
protected $fromStateIds;
/**
* The transition's to state ID.
*
* @var string
*/
protected $toStateId;
/**
* The transition's weight.
*
* @var int
*/
protected $weight;
/**
* Transition constructor.
*
* @param \Drupal\workflows\WorkflowTypeInterface $workflow
* The workflow the state is attached to.
* @param string $id
* The transition's ID.
* @param string $label
* The transition's label.
* @param array $from_state_ids
* A list of from state IDs.
* @param string $to_state_id
* The to state ID.
* @param int $weight
* (optional) The transition's weight. Defaults to 0.
*/
public function __construct(WorkflowTypeInterface $workflow, $id, $label, array $from_state_ids, $to_state_id, $weight = 0) {
$this->workflow = $workflow;
$this->id = $id;
$this->label = $label;
$this->fromStateIds = $from_state_ids;
$this->toStateId = $to_state_id;
$this->weight = $weight;
}
/**
* {@inheritdoc}
*/
public function id() {
return $this->id;
}
/**
* {@inheritdoc}
*/
public function label() {
return $this->label;
}
/**
* {@inheritdoc}
*/
public function from() {
return $this->workflow->getStates($this->fromStateIds);
}
/**
* {@inheritdoc}
*/
public function to() {
return $this->workflow->getState($this->toStateId);
}
/**
* {@inheritdoc}
*/
public function weight() {
return $this->weight;
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace Drupal\workflows;
/**
* A transition value object that describes the transition between two states.
*
* @internal
* The TransitionInterface should only be used by Workflows and Content
* Moderation.
*
* @todo Revisit the need for this in https://www.drupal.org/node/2902309.
*/
interface TransitionInterface {
/**
* The key of the transition plugin form.
*/
const PLUGIN_FORM_KEY = 'transition';
/**
* The transition direction from.
*/
const DIRECTION_FROM = 'from';
/**
* The transition direction to.
*/
const DIRECTION_TO = 'to';
/**
* Gets the transition's ID.
*
* @return string
* The transition's ID.
*/
public function id();
/**
* Gets the transition's label.
*
* @return string
* The transition's label.
*/
public function label();
/**
* Gets the transition's from states.
*
* @return \Drupal\workflows\StateInterface[]
* The transition's from states.
*/
public function from();
/**
* Gets the transition's to state.
*
* @return \Drupal\workflows\StateInterface
* The transition's to state.
*/
public function to();
/**
* Gets the transition's weight.
*
* @return string
* The transition's weight.
*/
public function weight();
}

View File

@ -0,0 +1,82 @@
<?php
namespace Drupal\workflows;
use Drupal\Component\Plugin\PluginManagerInterface;
use Drupal\Core\Entity\EntityAccessControlHandler;
use Drupal\Core\Entity\EntityHandlerInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Access\AccessResult;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Access controller for the Workflow entity.
*
* @see \Drupal\workflows\Entity\Workflow.
*/
class WorkflowAccessControlHandler extends EntityAccessControlHandler implements EntityHandlerInterface {
/**
* The workflow type plugin manager.
*
* @var \Drupal\Component\Plugin\PluginManagerInterface
*/
protected $workflowTypeManager;
/**
* {@inheritdoc}
*/
public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
return new static(
$entity_type,
$container->get('plugin.manager.workflows.type')
);
}
/**
* Constructs the workflow access control handler instance.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type definition.
* @param \Drupal\Component\Plugin\PluginManagerInterface $workflow_type_manager
* The workflow type plugin manager.
*/
public function __construct(EntityTypeInterface $entity_type, PluginManagerInterface $workflow_type_manager) {
parent::__construct($entity_type);
$this->workflowTypeManager = $workflow_type_manager;
}
/**
* {@inheritdoc}
*/
protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
/** @var \Drupal\workflows\Entity\Workflow $entity */
$workflow_type = $entity->getTypePlugin();
if (str_starts_with($operation, 'delete-state')) {
[, $state_id] = explode(':', $operation, 2);
// Deleting a state is editing a workflow, but also we should forbid
// access if there is only one state.
return AccessResult::allowedIf(count($entity->getTypePlugin()->getStates()) > 1)
->andIf(parent::checkAccess($entity, 'edit', $account))
->andIf(AccessResult::allowedIf(!in_array($state_id, $workflow_type->getRequiredStates(), TRUE)))
->addCacheableDependency($entity);
}
return parent::checkAccess($entity, $operation, $account);
}
/**
* {@inheritdoc}
*/
protected function checkCreateAccess(AccountInterface $account, array $context, $entity_bundle = NULL) {
$workflow_types_count = count($this->workflowTypeManager->getDefinitions());
$admin_access = parent::checkCreateAccess($account, $context, $entity_bundle);
// Allow access if there is at least one workflow type. Since workflow types
// are provided by modules this is cacheable until extensions change.
return $admin_access
->andIf(AccessResult::allowedIf($workflow_types_count > 0));
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace Drupal\workflows;
use Drupal\Core\Config\Entity\ConfigEntityInterface;
/**
* Provides an interface for defining workflow entities.
*/
interface WorkflowInterface extends ConfigEntityInterface {
/**
* Gets the workflow type plugin.
*
* @return \Drupal\workflows\WorkflowTypeInterface
* The workflow type plugin.
*/
public function getTypePlugin();
}

View File

@ -0,0 +1,102 @@
<?php
namespace Drupal\workflows;
use Drupal\Component\Plugin\PluginManagerInterface;
use Drupal\Core\Config\Entity\ConfigEntityListBuilder;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Url;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provides a listing of Workflow entities.
*/
class WorkflowListBuilder extends ConfigEntityListBuilder {
/**
* The workflow type plugin manager.
*
* @var \Drupal\Component\Plugin\PluginManagerInterface
*/
protected $workflowTypeManager;
/**
* {@inheritdoc}
*/
public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) {
return new static(
$entity_type,
$container->get('entity_type.manager')->getStorage($entity_type->id()),
$container->get('plugin.manager.workflows.type')
);
}
/**
* Constructs a new WorkflowListBuilder object.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type definition.
* @param \Drupal\Core\Entity\EntityStorageInterface $storage
* The entity storage class.
* @param \Drupal\Component\Plugin\PluginManagerInterface $workflow_type_manager
* The workflow type plugin manager.
*/
public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $storage, PluginManagerInterface $workflow_type_manager) {
parent::__construct($entity_type, $storage);
$this->workflowTypeManager = $workflow_type_manager;
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'workflow_admin_overview_form';
}
/**
* {@inheritdoc}
*/
public function buildHeader() {
$header['label'] = $this->t('Workflow');
$header['type'] = $this->t('Type');
$header['states'] = $this->t('States');
return $header + parent::buildHeader();
}
/**
* {@inheritdoc}
*/
public function buildRow(EntityInterface $entity) {
/** @var \Drupal\workflows\WorkflowInterface $entity */
$row['label'] = $entity->label();
$row['type']['data'] = [
'#markup' => $entity->getTypePlugin()->label(),
];
$items = array_map([State::class, 'labelCallback'], $entity->getTypePlugin()->getStates());
$row['states']['data'] = [
'#theme' => 'item_list',
'#context' => ['list_style' => 'comma-list'],
'#items' => $items,
];
return $row + parent::buildRow($entity);
}
/**
* {@inheritdoc}
*/
public function render() {
$build = parent::render();
$workflow_types_count = count($this->workflowTypeManager->getDefinitions());
if ($workflow_types_count === 0) {
$build['table']['#empty'] = $this->t('There are no workflow types available. In order to create workflows you need to install a module that provides a workflow type. For example, the <a href=":content-moderation">Content Moderation</a> module provides a workflow type that enables workflows for content entities.', [':content-moderation' => Url::fromRoute('system.modules_list', [], ['fragment' => 'module-content-moderation'])->toString()]);
}
return $build;
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace Drupal\workflows;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Session\AccountInterface;
/**
* Provides an access check for state and transition operations.
*/
class WorkflowStateTransitionOperationsAccessCheck implements AccessInterface {
/**
* Checks access for operations of workflow states and transitions.
*
* The value of '_workflow_access' is used to check to kind of access that
* should be applied to a route in the context of a workflow and a state or
* transition. States and transitions can individually have access control
* applied to them for 'add', 'update' and 'delete'. By default workflows will
* use the admin permission 'administer workflows' for all of these
* operations, except for delete-state which checks there is at least one
* state, a state does not have data and it's not a required state.
*
* For the update and delete operations, a workflow and a state or transition
* is required in the route for the access check to be applied. For the "add"
* operation, only a workflow is required. The '_workflow_access' requirement
* translates into access checks on the workflow entity type in the formats:
* - "$operation-state:$state_id"
* - "$operation-transition:$transition_id"
*
* For example the following route definition with the path
* "/test-workflow/foo-state/delete" the 'delete-state:foo-state' operation
* will be checked:
* @code
* path: '/{workflow}/{workflow_state}/delete'
* requirements:
* _workflow_access: 'delete-state'
* @endcode
*
* @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
* An access result.
*
* @throws \Exception
* Throws an exception when a route is defined with an invalid operation.
*/
public function access(RouteMatchInterface $route_match, AccountInterface $account) {
$workflow_operation = $this->getOperation($route_match);
if (!preg_match('/^(?<operation>add|update|delete)-(?<type>state|transition)$/', $workflow_operation, $matches)) {
throw new \Exception("Invalid _workflow_access operation '$workflow_operation' specified for route '{$route_match->getRouteName()}'.");
}
$parameters = $route_match->getParameters();
$workflow = $parameters->get('workflow');
if ($workflow && $matches['operation'] === 'add') {
return $workflow->access($workflow_operation, $account, TRUE);
}
if ($workflow && $type = $parameters->get(sprintf('workflow_%s', $matches['type']))) {
return $workflow->access(sprintf('%s:%s', $workflow_operation, $type), $account, TRUE);
}
return AccessResult::neutral();
}
/**
* Get the operation that will be used for the access check.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The parametrized route.
*
* @return string
* The access operation.
*/
protected function getOperation(RouteMatchInterface $route_match) {
return $route_match->getRouteObject()->getRequirement('_workflow_access');
}
}

View File

@ -0,0 +1,352 @@
<?php
namespace Drupal\workflows;
use Drupal\Component\Plugin\ConfigurableInterface;
use Drupal\Component\Plugin\DependentPluginInterface;
use Drupal\Component\Plugin\DerivativeInspectionInterface;
use Drupal\Core\Plugin\PluginWithFormsInterface;
/**
* An interface for Workflow type plugins.
*/
interface WorkflowTypeInterface extends PluginWithFormsInterface, DerivativeInspectionInterface, ConfigurableInterface, DependentPluginInterface {
/**
* The key of the global workflow plugin form.
*/
const PLUGIN_FORM_KEY = 'configure';
/**
* Gets the label for the workflow type.
*
* @return string
* The workflow type label.
*/
public function label();
/**
* Determines if the workflow is being has data associated with it.
*
* @param \Drupal\workflows\WorkflowInterface $workflow
* The workflow to check.
*
* @return bool
* TRUE if the workflow is being used, FALSE if not.
*
* @internal
* Marked as internal until it's validated this should form part of the
* public API in https://www.drupal.org/node/2897148.
*/
public function workflowHasData(WorkflowInterface $workflow);
/**
* Determines if the workflow state has data associated with it.
*
* @param \Drupal\workflows\WorkflowInterface $workflow
* The workflow to check.
* @param \Drupal\workflows\StateInterface $state
* The workflow state to check.
*
* @return bool
* TRUE if the workflow state is being used, FALSE if not.
*
* @internal
* Marked as internal until it's validated this should form part of the
* public API in https://www.drupal.org/node/2897148.
*/
public function workflowStateHasData(WorkflowInterface $workflow, StateInterface $state);
/**
* Gets the initial state for the workflow.
*
* @return \Drupal\workflows\StateInterface
* The initial state.
*/
public function getInitialState();
/**
* Gets the required states of workflow type.
*
* This is usually specified in the workflow type annotation.
*
* @return string[]
* The required states.
*
* @see \Drupal\workflows\Annotation\WorkflowType
*/
public function getRequiredStates();
/**
* Informs the plugin that a dependency of the workflow will be deleted.
*
* @param array $dependencies
* An array of dependencies that will be deleted keyed by dependency type.
*
* @return bool
* TRUE if the workflow settings have been changed, FALSE if not.
*
* @see \Drupal\Core\Config\ConfigEntityInterface::onDependencyRemoval()
*
* @todo https://www.drupal.org/node/2579743 make part of a generic interface.
*/
public function onDependencyRemoval(array $dependencies);
/**
* Adds a state to the workflow.
*
* @param string $state_id
* The state's ID.
* @param string $label
* The state's label.
*
* @return $this
*
* @throws \InvalidArgumentException
* Thrown if a state already exists or state ID is invalid.
*/
public function addState($state_id, $label);
/**
* Determines if the workflow has a state with the provided ID.
*
* @param string $state_id
* The state's ID.
*
* @return bool
* TRUE if the workflow has a state with the provided ID, FALSE if not.
*/
public function hasState($state_id);
/**
* Gets state objects for the provided state IDs.
*
* @param string[] $state_ids
* A list of state IDs to get. If NULL then all states will be returned.
*
* @return \Drupal\workflows\StateInterface[]
* An array of workflow states, keyed by state IDs.
*
* @throws \InvalidArgumentException
* Thrown if $state_ids contains a state ID that does not exist.
*/
public function getStates($state_ids = NULL);
/**
* Gets a workflow state.
*
* @param string $state_id
* The state's ID.
*
* @return \Drupal\workflows\StateInterface
* The workflow state.
*
* @throws \InvalidArgumentException
* Thrown if $state_id does not exist.
*/
public function getState($state_id);
/**
* Sets a state's label.
*
* @param string $state_id
* The state ID to set the label for.
* @param string $label
* The state's label.
*
* @return $this
*/
public function setStateLabel($state_id, $label);
/**
* Sets a state's weight value.
*
* @param string $state_id
* The state ID to set the weight for.
* @param int $weight
* The state's weight.
*
* @return $this
*/
public function setStateWeight($state_id, $weight);
/**
* Deletes a state from the workflow.
*
* @param string $state_id
* The state ID to delete.
*
* @return $this
* The workflow type plugin.
*
* @throws \InvalidArgumentException
* Thrown if $state_id does not exist.
*/
public function deleteState($state_id);
/**
* Adds a transition to the workflow.
*
* @param string $id
* The transition ID.
* @param string $label
* The transition's label.
* @param array $from_state_ids
* The state IDs to transition from.
* @param string $to_state_id
* The state ID to transition to.
*
* @return $this
*
* @throws \InvalidArgumentException
* Thrown if either state does not exist.
*/
public function addTransition($id, $label, array $from_state_ids, $to_state_id);
/**
* Gets a transition object for the provided transition ID.
*
* @param string $transition_id
* A transition ID.
*
* @return \Drupal\workflows\TransitionInterface
* The transition.
*
* @throws \InvalidArgumentException
* Thrown if $transition_id does not exist.
*/
public function getTransition($transition_id);
/**
* Determines if a transition exists.
*
* @param string $transition_id
* The transition ID.
*
* @return bool
* TRUE if the transition exists, FALSE if not.
*/
public function hasTransition($transition_id);
/**
* Gets transition objects for the provided transition IDs.
*
* @param string[] $transition_ids
* A list of transition IDs to get. If NULL then all transitions will be
* returned.
*
* @return \Drupal\workflows\TransitionInterface[]
* An array of transition objects.
*
* @throws \InvalidArgumentException
* Thrown if $transition_ids contains a transition ID that does not exist.
*/
public function getTransitions(?array $transition_ids = NULL);
/**
* Gets the transitions for a state for the provided direction.
*
* @param string $state_id
* The state to get transitions for.
* @param string $direction
* (optional) The direction of the transition, defaults to
* TransitionInterface::DIRECTION_FROM. Possible values are:
* TransitionInterface::DIRECTION_FROM or TransitionInterface::DIRECTION_TO.
*
* @return \Drupal\workflows\TransitionInterface[]
* An array of the transition objects for the state in the given direction,
* keyed by transition ID.
*
* @see \Drupal\workflows\TransitionInterface::DIRECTION_FROM
* @see \Drupal\workflows\TransitionInterface::DIRECTION_TO
*/
public function getTransitionsForState($state_id, $direction = TransitionInterface::DIRECTION_FROM);
/**
* Gets a transition from state to state.
*
* @param string $from_state_id
* The state ID to transition from.
* @param string $to_state_id
* The state ID to transition to.
*
* @return \Drupal\workflows\TransitionInterface
* The transitions.
*
* @throws \InvalidArgumentException
* Thrown if the transition does not exist.
*/
public function getTransitionFromStateToState($from_state_id, $to_state_id);
/**
* Determines if a transition from state to state exists.
*
* @param string $from_state_id
* The state ID to transition from.
* @param string $to_state_id
* The state ID to transition to.
*
* @return bool
* TRUE if the transition exists, FALSE if not.
*/
public function hasTransitionFromStateToState($from_state_id, $to_state_id);
/**
* Sets a transition's label.
*
* @param string $transition_id
* The transition ID.
* @param string $label
* The transition's label.
*
* @return $this
*
* @throws \InvalidArgumentException
* Thrown if the transition does not exist.
*/
public function setTransitionLabel($transition_id, $label);
/**
* Sets a transition's weight.
*
* @param string $transition_id
* The transition ID.
* @param int $weight
* The transition's weight.
*
* @return $this
*
* @throws \InvalidArgumentException
* Thrown if the transition does not exist.
*/
public function setTransitionWeight($transition_id, $weight);
/**
* Sets a transition's from states.
*
* @param string $transition_id
* The transition ID.
* @param array $from_state_ids
* The state IDs to transition from.
*
* @return $this
*
* @throws \InvalidArgumentException
* Thrown if the transition does not exist or the states do not exist.
*/
public function setTransitionFromStates($transition_id, array $from_state_ids);
/**
* Deletes a transition.
*
* @param string $transition_id
* The transition ID.
*
* @return $this
*
* @throws \InvalidArgumentException
* Thrown if the transition does not exist.
*/
public function deleteTransition($transition_id);
}

View File

@ -0,0 +1,36 @@
<?php
namespace Drupal\workflows;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Plugin\DefaultPluginManager;
use Drupal\workflows\Attribute\WorkflowType;
/**
* Provides a Workflow type plugin manager.
*
* @see \Drupal\workflows\Annotation\WorkflowType
* @see \Drupal\workflows\WorkflowTypeInterface
* @see plugin_api
*/
class WorkflowTypeManager extends DefaultPluginManager {
/**
* Constructs a new class instance.
*
* @param \Traversable $namespaces
* An object that implements \Traversable which contains the root paths
* keyed by the corresponding namespace to look for plugin implementations.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
* Cache backend instance to use.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler to invoke the alter hook with.
*/
public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
parent::__construct('Plugin/WorkflowType', $namespaces, $module_handler, WorkflowTypeInterface::class, WorkflowType::class, 'Drupal\workflows\Annotation\WorkflowType');
$this->alterInfo('workflow_type_info');
$this->setCacheBackend($cache_backend, 'workflow_type_info');
}
}

View File

@ -0,0 +1,2 @@
workflows.workflow.*.third_party.workflow_third_party_settings_test:
type: ignore

View File

@ -0,0 +1,7 @@
name: 'Workflow Third Party Settings Test'
type: module
description: 'Allows third party settings on workflows to be tested.'
package: Testing
version: VERSION
dependencies:
- drupal:workflows

View File

@ -0,0 +1,71 @@
workflow_type_test.ignore_schema:
type: mapping
label: 'Ignore schema workflow type'
mapping:
states:
type: sequence
sequence:
type: ignore
transitions:
type: sequence
sequence:
type: ignore
workflow.type_settings.workflow_type_test:
type: workflow_type_test.ignore_schema
label: 'Workflow test type settings'
workflow.type_settings.workflow_type_required_state_test:
type: workflow_type_test.ignore_schema
label: 'Workflow test type required state'
workflow.type_settings.workflow_custom_access_type:
type: workflow_type_test.ignore_schema
label: 'Workflow custom access type'
# @todo, inline this straight into "workflow.type_settings.workflow_type_complex_test"
# after https://www.drupal.org/node/2871746 is resolved.
workflows.state.complex_test_state:
type: workflows.state
mapping:
extra:
type: string
label: 'Extra information'
workflows.state.complex_test_transition:
type: workflows.transition
mapping:
extra:
type: string
label: 'Extra information'
workflow.type_settings.workflow_type_complex_test:
type: mapping
label: 'Workflow complex test type settings'
mapping:
example_setting:
type: string
label: 'Example setting'
states:
type: sequence
label: 'States'
sequence:
type: workflows.state.complex_test_state
label: 'States'
transitions:
type: sequence
label: 'Transitions'
sequence:
label: 'Transitions'
type: workflows.state.complex_test_transition
workflow.type_settings.predefined_states_workflow_test_type:
type: mapping
label: 'Predefined states workflow test type'
mapping:
transitions:
type: sequence
label: 'Transitions'
sequence:
label: 'Transitions'
type: workflows.transition

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Drupal\workflow_type_test\Form;
use Drupal\Core\Form\FormStateInterface;
use Drupal\workflows\Plugin\WorkflowTypeConfigureFormBase;
/**
* Form to configure the complex test workflow type.
*
* @see \Drupal\workflow_type_test\Plugin\WorkflowType\ComplexTestType
*/
class ComplexTestTypeConfigureForm extends WorkflowTypeConfigureFormBase {
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$type_configuration = $this->workflowType->getConfiguration();
$form['example_setting'] = [
'#type' => 'textfield',
'#title' => $this->t('Example global workflow setting'),
'#description' => $this->t('Extra information added to the workflow'),
'#default_value' => $type_configuration['example_setting'],
];
return $form;
}
/**
* {@inheritdoc}
*/
public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
$type_configuration = $this->workflowType->getConfiguration();
$type_configuration['example_setting'] = $form_state->getValue('example_setting');
$this->workflowType->setConfiguration($type_configuration);
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Drupal\workflow_type_test\Form;
use Drupal\Core\Form\FormStateInterface;
use Drupal\workflows\Plugin\WorkflowTypeStateFormBase;
/**
* Form to configure the complex test workflow states.
*
* @see \Drupal\workflow_type_test\Plugin\WorkflowType\ComplexTestType
*/
class ComplexTestTypeStateForm extends WorkflowTypeStateFormBase {
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$state = $form_state->get('state');
$configuration = $this->workflowType->getConfiguration();
$form['extra'] = [
'#type' => 'textfield',
'#title' => $this->t('Extra'),
'#description' => $this->t('Extra information added to state'),
'#default_value' => $state && isset($configuration['states'][$state->id()]['extra']) ? $configuration['states'][$state->id()]['extra'] : '',
];
return $form;
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Drupal\workflow_type_test\Form;
use Drupal\Core\Form\FormStateInterface;
use Drupal\workflows\Plugin\WorkflowTypeTransitionFormBase;
/**
* Form to configure the complex test workflow states.
*
* @see \Drupal\workflow_type_test\Plugin\WorkflowType\ComplexTestType
*/
class ComplexTestTypeTransitionForm extends WorkflowTypeTransitionFormBase {
/**
* {@inheritdoc}
*/
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$transition = $form_state->get('transition');
$configuration = $this->workflowType->getConfiguration();
$form['extra'] = [
'#type' => 'textfield',
'#title' => $this->t('Extra'),
'#description' => $this->t('Extra information added to transition'),
'#default_value' => $transition && isset($configuration['transitions'][$transition->id()]['extra']) ? $configuration['transitions'][$transition->id()]['extra'] : '',
];
return $form;
}
}

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Drupal\workflow_type_test\Hook;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Access\AccessResultInterface;
use Drupal\workflow_type_test\Plugin\WorkflowType\WorkflowCustomAccessType;
use Drupal\Core\Session\AccountInterface;
use Drupal\workflows\WorkflowInterface;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for workflow_type_test.
*/
class WorkflowTypeTestHooks {
/**
* Implements hook_workflow_type_info_alter().
*/
#[Hook('workflow_type_info_alter')]
public function workflowTypeInfoAlter(&$definitions): void {
// Allow tests to override the workflow type definitions.
$state = \Drupal::state();
if ($state->get('workflow_type_test.plugin_definitions') !== NULL) {
$definitions = $state->get('workflow_type_test.plugin_definitions');
}
}
/**
* Implements hook_ENTITY_TYPE_access() for the Workflow entity type.
*/
#[Hook('workflow_access')]
public function workflowAccess(WorkflowInterface $entity, $operation, AccountInterface $account): AccessResultInterface {
if ($entity->getTypePlugin()->getPluginId() === 'workflow_custom_access_type') {
return WorkflowCustomAccessType::workflowAccess($entity, $operation, $account);
}
return AccessResult::neutral();
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Drupal\workflow_type_test\Plugin\WorkflowType;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\workflows\Attribute\WorkflowType;
use Drupal\workflows\Plugin\WorkflowTypeBase;
/**
* Test workflow type.
*/
#[WorkflowType(
id: 'workflow_type_complex_test',
label: new TranslatableMarkup('Workflow Type Complex Test'),
forms: [
'configure' => '\Drupal\workflow_type_test\Form\ComplexTestTypeConfigureForm',
'state' => '\Drupal\workflow_type_test\Form\ComplexTestTypeStateForm',
'transition' => '\Drupal\workflow_type_test\Form\ComplexTestTypeTransitionForm',
]
)]
class ComplexTestType extends WorkflowTypeBase {
use StringTranslationTrait;
/**
* {@inheritdoc}
*/
public function onDependencyRemoval(array $dependencies) {
// Always return TRUE to allow the logic in
// \Drupal\workflows\Entity\Workflow::onDependencyRemoval() to be tested.
return TRUE;
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return parent::defaultConfiguration() + [
'example_setting' => '',
];
}
}

View File

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Drupal\workflow_type_test\Plugin\WorkflowType;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\workflows\Attribute\WorkflowType;
use Drupal\workflows\Plugin\WorkflowTypeBase;
use Drupal\workflows\State;
/**
* Test workflow type.
*/
#[WorkflowType(
id: 'predefined_states_workflow_test_type',
label: new TranslatableMarkup('Predefined States Workflow Test Type'),
required_states: [
'pay_blinds',
'bet',
'raise',
'fold',
]
)]
class PredefinedStatesWorkflowTestType extends WorkflowTypeBase {
/**
* {@inheritdoc}
*/
public function getStates($state_ids = NULL) {
return array_filter([
'pay_blinds' => new State($this, 'pay_blinds', 'Pay Blinds'),
'bet' => new State($this, 'bet', 'Bet'),
'raise' => new State($this, 'raise', 'Raise'),
'fold' => new State($this, 'fold', 'Fold'),
], function ($state) use ($state_ids) {
return is_array($state_ids) ? in_array($state->id(), $state_ids) : TRUE;
});
}
/**
* {@inheritdoc}
*/
public function getState($state_id) {
$states = $this->getStates();
if (!isset($states[$state_id])) {
throw new \InvalidArgumentException("The state '$state_id' does not exist in workflow.'");
}
return $states[$state_id];
}
/**
* {@inheritdoc}
*/
public function hasState($state_id) {
$states = $this->getStates();
return isset($states[$state_id]);
}
/**
* {@inheritdoc}
*/
public function addState($state_id, $label) {
// States cannot be added on this workflow.
return $this;
}
/**
* {@inheritdoc}
*/
public function setStateLabel($state_id, $label) {
// States cannot be altered on this workflow.
return $this;
}
/**
* {@inheritdoc}
*/
public function setStateWeight($state_id, $weight) {
// States cannot be altered on this workflow.
return $this;
}
/**
* {@inheritdoc}
*/
public function deleteState($state_id) {
// States cannot be deleted on this workflow.
return $this;
}
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'transitions' => [],
];
}
}

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Drupal\workflow_type_test\Plugin\WorkflowType;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\workflows\Attribute\WorkflowType;
use Drupal\workflows\Plugin\WorkflowTypeBase;
/**
* Test workflow type.
*/
#[WorkflowType(
id: 'workflow_type_required_state_test',
label: new TranslatableMarkup('Required State Type Test'),
required_states: [
'fresh',
'rotten',
]
)]
class RequiredStateTestType extends WorkflowTypeBase {
use StringTranslationTrait;
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'states' => [
'fresh' => [
'label' => 'Fresh',
'weight' => 0,
],
'rotten' => [
'label' => 'Rotten',
'weight' => 1,
],
],
'transitions' => [
'rot' => [
'label' => 'Rot',
'to' => 'rotten',
'weight' => 0,
'from' => [
'fresh',
],
],
],
];
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Drupal\workflow_type_test\Plugin\WorkflowType;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\workflows\Attribute\WorkflowType;
use Drupal\workflows\Plugin\WorkflowTypeBase;
/**
* Test workflow type.
*/
#[WorkflowType(
id: 'workflow_type_test',
label: new TranslatableMarkup('Workflow Type Test')
)]
class TestType extends WorkflowTypeBase {
/**
* {@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', []);
}
}

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Drupal\workflow_type_test\Plugin\WorkflowType;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\workflows\Attribute\WorkflowType;
use Drupal\workflows\Plugin\WorkflowTypeBase;
use Drupal\workflows\WorkflowInterface;
/**
* A test workflow with custom state/transition access rules applied.
*/
#[WorkflowType(
id: 'workflow_custom_access_type',
label: new TranslatableMarkup('Workflow Custom Access Type Test')
)]
class WorkflowCustomAccessType extends WorkflowTypeBase {
/**
* {@inheritdoc}
*/
public function defaultConfiguration() {
return [
'states' => [
'cannot_update' => [
'label' => 'Cannot Update State',
'weight' => 0,
],
'cannot_delete' => [
'label' => 'Cannot Delete State',
'weight' => 0,
],
],
'transitions' => [
'cannot_update' => [
'label' => 'Cannot Update Transition',
'to' => 'cannot_update',
'weight' => 0,
'from' => [
'cannot_update',
],
],
'cannot_delete' => [
'label' => 'Cannot Delete Transition',
'to' => 'cannot_delete',
'weight' => 1,
'from' => [
'cannot_delete',
],
],
],
];
}
/**
* Implements hook_ENTITY_TYPE_access().
*
* @see workflow_type_test_workflow_access
*/
public static function workflowAccess(WorkflowInterface $entity, $operation, AccountInterface $account) {
$forbidden_operations = \Drupal::state()->get('workflow_type_test_forbidden_operations', []);
return in_array($operation, $forbidden_operations, TRUE)
? AccessResult::forbidden()
: AccessResult::neutral();
}
}

View File

@ -0,0 +1,7 @@
name: 'Workflow Type Test'
type: module
description: 'Provides a workflow type plugin for testing.'
package: Testing
version: VERSION
dependencies:
- drupal:workflows

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workflows\Functional;
use Drupal\Tests\system\Functional\Module\GenericModuleTestBase;
/**
* Generic module test for workflows.
*
* @group workflows
*/
class GenericTest extends GenericModuleTestBase {}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workflows\Functional\Rest;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
/**
* @group rest
*/
class WorkflowJsonAnonTest extends WorkflowResourceTestBase {
use AnonResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workflows\Functional\Rest;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
/**
* @group rest
*/
class WorkflowJsonBasicAuthTest extends WorkflowResourceTestBase {
use BasicAuthResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['basic_auth'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected static $auth = 'basic_auth';
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workflows\Functional\Rest;
use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
/**
* @group rest
*/
class WorkflowJsonCookieTest extends WorkflowResourceTestBase {
use CookieResourceTestTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'json';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'application/json';
/**
* {@inheritdoc}
*/
protected static $auth = 'cookie';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workflows\Functional\Rest;
use Drupal\Tests\rest\Functional\EntityResource\ConfigEntityResourceTestBase;
use Drupal\workflows\Entity\Workflow;
/**
* Resource test base for Workflow entity.
*/
abstract class WorkflowResourceTestBase extends ConfigEntityResourceTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'workflows',
'workflow_type_test',
];
/**
* {@inheritdoc}
*/
protected static $entityTypeId = 'workflow';
/**
* {@inheritdoc}
*/
protected static $patchProtectedFieldNames = [];
/**
* The Workflow entity.
*
* @var \Drupal\workflows\WorkflowInterface
*/
protected $entity;
/**
* {@inheritdoc}
*/
protected function setUpAuthorization($method) {
$this->grantPermissionsToTestedRole(['administer workflows']);
}
/**
* {@inheritdoc}
*/
protected function createEntity() {
$workflow = Workflow::create([
'id' => 'rest_workflow',
'label' => 'REST Workflow',
'type' => 'workflow_type_complex_test',
]);
$workflow
->getTypePlugin()
->addState('draft', 'Draft')
->addState('published', 'Published');
$configuration = $workflow->getTypePlugin()->getConfiguration();
$configuration['example_setting'] = 'foo';
$configuration['states']['draft']['extra'] = 'bar';
$workflow->getTypePlugin()->setConfiguration($configuration);
$workflow->save();
return $workflow;
}
/**
* {@inheritdoc}
*/
protected function getExpectedNormalizedEntity() {
return [
'dependencies' => [
'module' => [
'workflow_type_test',
],
],
'id' => 'rest_workflow',
'label' => 'REST Workflow',
'langcode' => 'en',
'status' => TRUE,
'type' => 'workflow_type_complex_test',
'type_settings' => [
'states' => [
'draft' => [
'extra' => 'bar',
'label' => 'Draft',
'weight' => 0,
],
'published' => [
'label' => 'Published',
'weight' => 1,
],
],
'transitions' => [],
'example_setting' => 'foo',
],
'uuid' => $this->entity->uuid(),
];
}
/**
* {@inheritdoc}
*/
protected function getNormalizedPostEntity() {
// @todo Update in https://www.drupal.org/node/2300677.
return [];
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workflows\Functional\Rest;
use Drupal\Tests\rest\Functional\AnonResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
*/
class WorkflowXmlAnonTest extends WorkflowResourceTestBase {
use AnonResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workflows\Functional\Rest;
use Drupal\Tests\rest\Functional\BasicAuthResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
*/
class WorkflowXmlBasicAuthTest extends WorkflowResourceTestBase {
use BasicAuthResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $modules = ['basic_auth'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected static $auth = 'basic_auth';
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workflows\Functional\Rest;
use Drupal\Tests\rest\Functional\CookieResourceTestTrait;
use Drupal\Tests\rest\Functional\EntityResource\XmlEntityNormalizationQuirksTrait;
/**
* @group rest
*/
class WorkflowXmlCookieTest extends WorkflowResourceTestBase {
use CookieResourceTestTrait;
use XmlEntityNormalizationQuirksTrait;
/**
* {@inheritdoc}
*/
protected static $format = 'xml';
/**
* {@inheritdoc}
*/
protected static $mimeType = 'text/xml; charset=UTF-8';
/**
* {@inheritdoc}
*/
protected static $auth = 'cookie';
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
}

View File

@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workflows\Functional;
use Drupal\Tests\BrowserTestBase;
use Drupal\workflows\Entity\Workflow;
/**
* Test custom provided workflow access for state/transition operations.
*
* @group workflows
*/
class WorkflowCustomStateTransitionAccessTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'workflows',
'workflow_type_test',
];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* A test admin user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $adminUser;
/**
* A test workflow.
*
* @var \Drupal\workflows\WorkflowInterface
*/
protected $testWorkflow;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->adminUser = $this->createUser(['administer workflows']);
$this->testWorkflow = Workflow::create([
'label' => 'Test workflow',
'id' => 'test_type',
'type' => 'workflow_custom_access_type',
]);
$this->testWorkflow->save();
}
/**
* Tests the custom state/transition operation access rules.
*/
public function testCustomWorkflowAccessOperations(): void {
$this->drupalLogin($this->adminUser);
$forbidden_paths = [
'admin/config/workflow/workflows/manage/test_type/state/cannot_delete/delete',
'admin/config/workflow/workflows/manage/test_type/state/cannot_update',
'admin/config/workflow/workflows/manage/test_type/transition/cannot_update',
'admin/config/workflow/workflows/manage/test_type/transition/cannot_delete/delete',
'admin/config/workflow/workflows/manage/test_type/add_state',
'admin/config/workflow/workflows/manage/test_type/add_transition',
];
// Until the list of forbidden operations have been set, the admin user
// should be able to access all the forbidden paths.
foreach ($forbidden_paths as $forbidden_path) {
$this->drupalGet($forbidden_path);
$this->assertSession()->statusCodeEquals(200);
}
// Update the forbidden operations which deny access to the actions
// represented by the above paths.
$this->container->get('state')->set('workflow_type_test_forbidden_operations', [
'update-state:cannot_update',
'delete-state:cannot_delete',
'update-transition:cannot_update',
'delete-transition:cannot_delete',
'add-state',
'add-transition',
]);
foreach ($forbidden_paths as $forbidden_path) {
$this->drupalGet($forbidden_path);
$this->assertSession()->statusCodeEquals(403);
}
}
}

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workflows\Functional;
use Drupal\Core\Url;
use Drupal\Tests\BrowserTestBase;
/**
* Tests workflow UI when there are no types.
*
* @group workflows
*/
class WorkflowUiNoTypeTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['workflows', 'block'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// We're testing local actions.
$this->drupalPlaceBlock('local_actions_block');
}
/**
* Tests the creation of a workflow through the UI.
*/
public function testWorkflowUiWithNoType(): void {
$this->drupalLogin($this->createUser(['access administration pages', 'administer workflows']));
$this->drupalGet('admin/config/workflow/workflows/add');
// There are no workflow types so this should be a 403.
$this->assertSession()->statusCodeEquals(403);
$this->drupalGet('admin/config/workflow/workflows');
$this->assertSession()->pageTextContains('There are no workflow types available. In order to create workflows you need to install a module that provides a workflow type. For example, the Content Moderation module provides a workflow type that enables workflows for content entities.');
$this->assertSession()->linkExists('Content Moderation');
$this->assertSession()->pageTextNotContains('Add workflow');
$this->clickLink('Content Moderation');
$modules_list_url_absolute = Url::fromRoute('system.modules_list', [], [
'fragment' => 'module-content-moderation',
'absolute' => TRUE,
])->toString();
$this->assertSame($this->getSession()->getCurrentUrl(), $modules_list_url_absolute);
// The current user does not have the 'administer modules' permission.
$this->assertSession()->statusCodeEquals(403);
$this->container->get('module_installer')->install(['workflow_type_test']);
// The render cache needs to be cleared because although the cache tags are
// correctly set the render cache does not pick it up.
\Drupal::cache('render')->deleteAll();
$this->drupalGet('admin/config/workflow/workflows');
$this->assertSession()->pageTextNotContains('There are no workflow types available. In order to create workflows you need to install a module that provides a workflow type. For example, the Content Moderation module provides a workflow type that enables workflows for content entities.');
$this->assertSession()->linkExists('Add workflow');
$this->assertSession()->pageTextContains('There are no workflows yet.');
}
}

View File

@ -0,0 +1,424 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workflows\Functional;
use Drupal\Core\Url;
use Drupal\Tests\BrowserTestBase;
use Drupal\workflows\Entity\Workflow;
/**
* Tests workflow creation UI.
*
* @group workflows
*/
class WorkflowUiTest extends BrowserTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['workflows', 'workflow_type_test', 'block'];
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// We're testing local actions.
$this->drupalPlaceBlock('local_actions_block');
}
/**
* Tests route access/permissions.
*/
public function testAccess(): void {
// Create a minimal workflow for testing.
$workflow = Workflow::create(['id' => 'test', 'type' => 'workflow_type_test', 'label' => 'Test']);
$workflow
->getTypePlugin()
->addState('draft', 'Draft')
->addState('published', 'Published')
->addTransition('publish', 'Publish', ['draft', 'published'], 'published');
$workflow->save();
$paths = [
'admin/config/workflow/workflows',
'admin/config/workflow/workflows/add',
'admin/config/workflow/workflows/manage/test',
'admin/config/workflow/workflows/manage/test/delete',
'admin/config/workflow/workflows/manage/test/add_state',
'admin/config/workflow/workflows/manage/test/state/published',
'admin/config/workflow/workflows/manage/test/state/published/delete',
'admin/config/workflow/workflows/manage/test/add_transition',
'admin/config/workflow/workflows/manage/test/transition/publish',
'admin/config/workflow/workflows/manage/test/transition/publish/delete',
];
foreach ($paths as $path) {
$this->drupalGet($path);
// No access.
$this->assertSession()->statusCodeEquals(403);
}
$this->drupalLogin($this->createUser(['administer workflows']));
foreach ($paths as $path) {
$this->drupalGet($path);
// User has access.
$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/draft/delete');
$this->assertSession()->statusCodeEquals(403);
}
/**
* Tests the machine name validation of the state add form.
*/
public function testStateMachineNameValidation(): void {
Workflow::create([
'id' => 'test_workflow',
'label' => 'Test workflow',
'type' => 'workflow_type_test',
])->save();
$this->drupalLogin($this->createUser(['administer workflows']));
$this->drupalGet('admin/config/workflow/workflows/manage/test_workflow/add_state');
$this->submitForm([
'label' => 'Test State',
'id' => 'Invalid ID',
], 'Save');
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->pageTextContains('The machine-readable name must contain only lowercase letters, numbers, and underscores.');
$this->drupalGet('admin/config/workflow/workflows/manage/test_workflow/add_transition');
$this->submitForm([
'label' => 'Test Transition',
'id' => 'Invalid ID',
], 'Save');
$this->assertSession()->pageTextContains('The machine-readable name must contain only lowercase letters, numbers, and underscores.');
}
/**
* Tests the creation of a workflow through the UI.
*/
public function testWorkflowCreation(): void {
$workflow_storage = $this->container->get('entity_type.manager')->getStorage('workflow');
$this->drupalLogin($this->createUser(['access administration pages', 'administer workflows']));
$this->drupalGet('admin/config/workflow');
$this->assertSession()->linkByHrefExists('admin/config/workflow/workflows');
$this->clickLink('Workflows');
$this->assertSession()->pageTextContains('Workflows');
$this->assertSession()->pageTextContains('There are no workflows yet.');
$this->clickLink('Add workflow');
$this->submitForm(['label' => 'Test', 'id' => 'test', 'workflow_type' => 'workflow_type_test'], 'Save');
$this->assertSession()->pageTextContains('Created the Test Workflow.');
$this->assertSession()->addressEquals('admin/config/workflow/workflows/manage/test/add_state');
$this->drupalGet('/admin/config/workflow/workflows/manage/test');
$this->assertSession()->pageTextContains('This workflow has no states and will be disabled until there is at least one, add a new state.');
$this->assertSession()->pageTextContains('There are no states yet.');
$this->clickLink('Add a new state');
$this->submitForm(['label' => 'Published', 'id' => 'published'], 'Save');
$this->assertSession()->pageTextContains('Created Published state.');
/** @var \Drupal\workflows\WorkflowInterface $workflow */
$workflow = $workflow_storage->loadUnchanged('test');
$this->assertFalse($workflow->getTypePlugin()->getState('published')->canTransitionTo('published'), 'No default transition from published to published exists.');
$this->clickLink('Add a new state');
// Don't create a draft to draft transition by default.
$this->submitForm(['label' => 'Draft', 'id' => 'draft'], 'Save');
$this->assertSession()->pageTextContains('Created Draft state.');
$workflow = $workflow_storage->loadUnchanged('test');
$this->assertFalse($workflow->getTypePlugin()->getState('draft')->canTransitionTo('draft'), 'Can not transition from draft to draft');
$this->clickLink('Add a new transition');
$this->submitForm(['id' => 'publish', 'label' => 'Publish', 'from[draft]' => 'draft', 'to' => 'published'], 'Save');
$this->assertSession()->pageTextContains('Created Publish transition.');
$workflow = $workflow_storage->loadUnchanged('test');
$this->assertTrue($workflow->getTypePlugin()->getState('draft')->canTransitionTo('published'), 'Can transition from draft to published');
$this->clickLink('Add a new transition');
$this->assertCount(2, $this->cssSelect('input[name="to"][type="radio"]'));
$this->assertCount(0, $this->cssSelect('input[name="to"][checked="checked"][type="radio"]'));
$this->submitForm(['id' => 'create_new_draft', 'label' => 'Create new draft', 'from[draft]' => 'draft', 'to' => 'draft'], 'Save');
$this->assertSession()->pageTextContains('Created Create new draft transition.');
$workflow = $workflow_storage->loadUnchanged('test');
$this->assertTrue($workflow->getTypePlugin()->getState('draft')->canTransitionTo('draft'), 'Can transition from draft to draft');
// The fist state to edit on the page should be published.
$this->clickLink('Edit');
$this->assertSession()->fieldValueEquals('label', 'Published');
// Change the label.
$this->submitForm(['label' => 'Live'], 'Save');
$this->assertSession()->pageTextContains('Saved Live state.');
// Allow published to draft.
$this->clickLink('Edit', 3);
$this->submitForm(['from[published]' => 'published'], 'Save');
$this->assertSession()->pageTextContains('Saved Create new draft transition.');
$workflow = $workflow_storage->loadUnchanged('test');
$this->assertTrue($workflow->getTypePlugin()->getState('published')->canTransitionTo('draft'), 'Can transition from published to draft');
// Try creating a duplicate transition.
$this->clickLink('Add a new transition');
$this->submitForm(['id' => 'create_new_draft', 'label' => 'Create new draft', 'from[published]' => 'published', 'to' => 'draft'], 'Save');
$this->assertSession()->pageTextContains('The machine-readable name is already in use. It must be unique.');
// Try creating a transition which duplicates the states of another.
$this->submitForm(['id' => 'create_new_draft2', 'label' => 'Create new draft again', 'from[published]' => 'published', 'to' => 'draft'], 'Save');
$this->assertSession()->pageTextContains('The transition from Live to Draft already exists.');
// Create a new transition.
$this->submitForm(['id' => 'save_and_publish', 'label' => 'Save and publish', 'from[published]' => 'published', 'to' => 'published'], 'Save');
$this->assertSession()->pageTextContains('Created Save and publish transition.');
// Edit the new transition and try to add an existing transition.
$this->clickLink('Edit', 4);
$this->submitForm(['from[draft]' => 'draft'], 'Save');
$this->assertSession()->pageTextContains('The transition from Draft to Live already exists.');
// Delete the transition.
$workflow = $workflow_storage->loadUnchanged('test');
$this->assertTrue($workflow->getTypePlugin()->hasTransitionFromStateToState('published', 'published'), 'Can transition from published to published');
$this->clickLink('Delete');
$this->assertSession()->pageTextContains('Are you sure you want to delete Save and publish from Test?');
$this->submitForm([], 'Delete');
$workflow = $workflow_storage->loadUnchanged('test');
$this->assertFalse($workflow->getTypePlugin()->hasTransitionFromStateToState('published', 'published'), 'Cannot transition from published to published');
// Try creating a duplicate state.
$this->drupalGet('admin/config/workflow/workflows/manage/test');
$this->clickLink('Add a new state');
$this->submitForm(['label' => 'Draft', 'id' => 'draft'], 'Save');
$this->assertSession()->pageTextContains('The machine-readable name is already in use. It must be unique.');
// Ensure that weight changes the state ordering.
$workflow = $workflow_storage->loadUnchanged('test');
$this->assertEquals('published', $workflow->getTypePlugin()->getInitialState()->id());
$this->drupalGet('admin/config/workflow/workflows/manage/test');
$this->submitForm(['states[draft][weight]' => '-1'], 'Save');
$workflow = $workflow_storage->loadUnchanged('test');
$this->assertEquals('draft', $workflow->getTypePlugin()->getInitialState()->id());
// Verify that we are still on the workflow edit page.
$this->assertSession()->addressEquals('admin/config/workflow/workflows/manage/test');
// Ensure that weight changes the transition ordering.
$this->assertEquals(['publish', 'create_new_draft'], array_keys($workflow->getTypePlugin()->getTransitions()));
$this->drupalGet('admin/config/workflow/workflows/manage/test');
$this->submitForm(['transitions[create_new_draft][weight]' => '-1'], 'Save');
$workflow = $workflow_storage->loadUnchanged('test');
$this->assertEquals(['create_new_draft', 'publish'], array_keys($workflow->getTypePlugin()->getTransitions()));
// Verify that we are still on the workflow edit page.
$this->assertSession()->addressEquals('admin/config/workflow/workflows/manage/test');
// Ensure that a delete link for the published state exists before deleting
// the draft state.
$published_delete_link = Url::fromRoute('entity.workflow.delete_state_form', [
'workflow' => $workflow->id(),
'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');
$this->assertSession()->pageTextContains('Are you sure you want to delete Draft from Test?');
$this->submitForm([], 'Delete');
$this->assertSession()->pageTextContains('State Draft deleted.');
$workflow = $workflow_storage->loadUnchanged('test');
$this->assertFalse($workflow->getTypePlugin()->hasState('draft'), 'Draft state deleted');
$this->assertTrue($workflow->getTypePlugin()->hasState('published'), 'Workflow still has published state');
// The last state cannot be deleted so the only delete link on the page will
// be for the workflow.
$this->assertSession()->linkByHrefNotExists($published_delete_link);
$this->clickLink('Delete');
$this->assertSession()->pageTextContains('Are you sure you want to delete Test?');
$this->submitForm([], 'Delete');
$this->assertSession()->pageTextContains('Workflow Test deleted.');
$this->assertSession()->pageTextContains('There are no workflows yet.');
$this->assertNull($workflow_storage->loadUnchanged('test'), 'The test workflow has been deleted');
// Ensure that workflow types with default configuration 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->getTypePlugin()->hasState('fresh'), 'The workflow has the "fresh" state');
$this->assertTrue($workflow->getTypePlugin()->hasState('rotten'), 'The workflow has the "rotten" state');
$this->assertTrue($workflow->getTypePlugin()->hasTransition('rot'), 'The workflow has the "rot" transition');
$this->assertSession()->pageTextContains('Fresh');
$this->assertSession()->pageTextContains('Rotten');
}
/**
* Tests the workflow configuration form.
*/
public function testWorkflowConfigurationForm(): void {
$workflow = Workflow::create(['id' => 'test', 'type' => 'workflow_type_complex_test', 'label' => 'Test']);
$workflow
->getTypePlugin()
->addState('published', 'Published')
->addTransition('publish', 'Publish', ['published'], 'published');
$workflow->save();
$this->drupalLogin($this->createUser(['administer workflows']));
// Add additional information to the workflow via the configuration form.
$this->drupalGet('admin/config/workflow/workflows/manage/test');
$this->assertSession()->pageTextContains('Example global workflow setting');
$this->submitForm(['type_settings[example_setting]' => 'Extra global settings'], 'Save');
$workflow_storage = $this->container->get('entity_type.manager')->getStorage('workflow');
$workflow = $workflow_storage->loadUnchanged('test');
$this->assertEquals('Extra global settings', $workflow->getTypePlugin()->getConfiguration()['example_setting']);
}
/**
* Tests a workflow, state, and transition can have a numeric ID and label.
*/
public function testNumericIds(): void {
$this->drupalLogin($this->createUser(['administer workflows']));
$this->drupalGet('admin/config/workflow/workflows');
$this->clickLink('Add workflow');
$this->submitForm(['label' => 123, 'id' => 123, 'workflow_type' => 'workflow_type_complex_test'], 'Save');
$this->assertSession()->addressEquals('admin/config/workflow/workflows/manage/123/add_state');
$this->submitForm(['label' => 456, 'id' => 456], 'Save');
$this->assertSession()->pageTextContains('Created 456 state.');
$this->clickLink('Add a new state');
$this->submitForm(['label' => 789, 'id' => 789], 'Save');
$this->assertSession()->pageTextContains('Created 789 state.');
$this->clickLink('Add a new transition');
$this->submitForm(['id' => 101112, 'label' => 101112, 'from[456]' => 456, 'to' => 789], 'Save');
$this->assertSession()->pageTextContains('Created 101112 transition.');
$workflow = $this->container->get('entity_type.manager')->getStorage('workflow')->loadUnchanged(123);
$this->assertEquals(123, $workflow->id());
$this->assertEquals(456, $workflow->getTypePlugin()->getState(456)->id());
$this->assertEquals(101112, $workflow->getTypePlugin()->getTransition(101112)->id());
$this->assertEquals(789, $workflow->getTypePlugin()->getTransition(101112)->to()->id());
}
/**
* Tests the sorting of states and transitions by weight and label.
*/
public function testSorting(): void {
$workflow = Workflow::create(['id' => 'test', 'type' => 'workflow_type_complex_test', 'label' => 'Test']);
$workflow
->getTypePlugin()
->setConfiguration([
'states' => [
'two_a' => [
'label' => 'two a',
'weight' => 2,
],
'three' => [
'label' => 'three',
'weight' => 3,
],
'two_b' => [
'label' => 'two b',
'weight' => 2,
],
'one' => [
'label' => 'one',
'weight' => 1,
],
],
'transitions' => [
'three' => [
'label' => 'three',
'from' => ['three'],
'to' => 'three',
'weight' => 3,
],
'two_a' => [
'label' => 'two a',
'from' => ['two_a'],
'to' => 'two_a',
'weight' => 2,
],
'one' => [
'label' => 'one',
'from' => ['one'],
'to' => 'one',
'weight' => 1,
],
'two_b' => [
'label' => 'two b',
'from' => ['two_b'],
'to' => 'two_b',
'weight' => 2,
],
],
]);
$workflow->save();
$this->drupalLogin($this->createUser(['administer workflows']));
$this->drupalGet('admin/config/workflow/workflows/manage/test');
$expected_states = ['one', 'two a', 'two b', 'three'];
$elements = $this->xpath('//details[@id="edit-states-container"]//table/tbody/tr');
foreach ($elements as $key => $element) {
$this->assertEquals($expected_states[$key], $element->find('xpath', 'td')->getText());
}
$expected_transitions = ['one', 'two a', 'two b', 'three'];
$elements = $this->xpath('//details[@id="edit-transitions-container"]//table/tbody/tr');
foreach ($elements as $key => $element) {
$this->assertEquals($expected_transitions[$key], $element->find('xpath', 'td')->getText());
}
// Ensure that there are enough weights to satisfy the potential number of
// states and transitions.
$this->assertSession()
->selectExists('states[three][weight]')
->selectOption('2');
$this->assertSession()
->selectExists('states[three][weight]')
->selectOption('-2');
$this->assertSession()
->selectExists('transitions[three][weight]')
->selectOption('2');
$this->assertSession()
->selectExists('transitions[three][weight]')
->selectOption('-2');
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workflows\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\workflows\Entity\Workflow;
/**
* Workflow entity tests that require modules or storage.
*
* @coversDefaultClass \Drupal\workflow_type_test\Plugin\WorkflowType\ComplexTestType
*
* @group workflows
*/
class ComplexWorkflowTypeTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['workflows', 'workflow_type_test'];
/**
* @covers \Drupal\workflows\Entity\Workflow::loadMultipleByType
*/
public function testLoadMultipleByType(): void {
$workflow1 = Workflow::create([
'id' => 'test1',
'label' => 'Test 1',
'type' => 'workflow_type_complex_test',
]);
$workflow1->save();
$workflow2 = Workflow::create([
'id' => 'test2',
'label' => 'Test 2',
'type' => 'workflow_type_complex_test',
]);
$workflow2->save();
$workflow3 = Workflow::create([
'id' => 'test3',
'label' => 'Test 3',
'type' => 'workflow_type_test',
]);
$workflow3->save();
$this->assertEquals(['test1', 'test2'], array_keys(Workflow::loadMultipleByType('workflow_type_complex_test')));
$this->assertEquals(['test3'], array_keys(Workflow::loadMultipleByType('workflow_type_test')));
$this->assertEquals([], Workflow::loadMultipleByType('a_type_that_does_not_exist'));
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workflows\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\workflows\Entity\Workflow;
/**
* Test a predefined workflow based on something other than configuration.
*
* @group workflows
*/
class PredefinedWorkflowTypeTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['workflows', 'workflow_type_test'];
/**
* Tests a predefined workflow type.
*/
public function testPredefinedWorkflowType(): void {
$workflow = Workflow::create([
'id' => 'aces',
'label' => 'Aces Workflow',
'type' => 'predefined_states_workflow_test_type',
'transitions' => [
'bet' => [
'label' => 'Bet',
'from' => [
'pay_blinds',
],
'to' => 'bet',
],
'raise' => [
'label' => 'Raise',
'from' => [
'pay_blinds',
],
'to' => 'raise',
],
],
]);
$workflow->save();
// No states configuration is stored for this workflow.
$configuration = $workflow->getTypePlugin()->getConfiguration();
$this->assertFalse(isset($configuration['states']));
}
}

View File

@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workflows\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\workflows\Entity\Workflow;
use Drupal\workflows\Exception\RequiredStateMissingException;
/**
* Tests Workflow type's required states and configuration initialization.
*
* @coversDefaultClass \Drupal\workflows\Plugin\WorkflowTypeBase
*
* @group workflows
*/
class RequiredStatesTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['workflows', 'workflow_type_test'];
/**
* @covers ::getRequiredStates
* @covers ::__construct
*/
public function testGetRequiredStates(): void {
$workflow = Workflow::create([
'id' => 'test',
'label' => 'Test workflow',
'type' => 'workflow_type_required_state_test',
]);
$workflow->save();
$this->assertEquals(['fresh', 'rotten'], $workflow->getTypePlugin()
->getRequiredStates());
// Ensure that the workflow has the default configuration.
$this->assertTrue($workflow->getTypePlugin()->hasState('rotten'));
$this->assertTrue($workflow->getTypePlugin()->hasState('fresh'));
$this->assertTrue($workflow->getTypePlugin()->hasTransitionFromStateToState('fresh', 'rotten'));
}
/**
* @covers \Drupal\workflows\Entity\Workflow::preSave
*/
public function testDeleteRequiredStateAPI(): void {
$workflow = Workflow::create([
'id' => 'test',
'label' => 'Test workflow',
'type' => 'workflow_type_required_state_test',
]);
$workflow->save();
// Ensure that required states can't be deleted.
$this->expectException(RequiredStateMissingException::class);
$this->expectExceptionMessage("Required State Type Test' requires states with the ID 'fresh' in workflow 'test'");
$workflow->getTypePlugin()->deleteState('fresh');
$workflow->save();
}
/**
* @covers \Drupal\workflows\Entity\Workflow::preSave
*/
public function testNoStatesRequiredStateAPI(): void {
$workflow = Workflow::create([
'id' => 'test',
'type' => 'workflow_type_required_state_test',
'type_settings' => [
'states' => [],
],
]);
$this->expectException(RequiredStateMissingException::class);
$this->expectExceptionMessage("Required State Type Test' requires states with the ID 'fresh', 'rotten' in workflow 'test'");
$workflow->save();
}
/**
* Ensures that initialized configuration can be changed.
*/
public function testChangeRequiredStateAPI(): void {
$workflow = Workflow::create([
'id' => 'test',
'label' => 'Test workflow',
'type' => 'workflow_type_required_state_test',
]);
$workflow->save();
// Ensure states added by default configuration can be changed.
$this->assertEquals('Fresh', $workflow->getTypePlugin()->getState('fresh')->label());
$workflow
->getTypePlugin()
->setStateLabel('fresh', 'Fresher');
$workflow->save();
$this->assertEquals('Fresher', $workflow->getTypePlugin()->getState('fresh')->label());
// Ensure transitions can be altered.
$workflow
->getTypePlugin()
->addState('cooked', 'Cooked')
->setTransitionFromStates('rot', ['fresh', 'cooked']);
$workflow->save();
$this->assertTrue($workflow->getTypePlugin()->hasTransitionFromStateToState('fresh', 'rotten'));
$this->assertTrue($workflow->getTypePlugin()->hasTransitionFromStateToState('cooked', 'rotten'));
$workflow
->getTypePlugin()
->setTransitionFromStates('rot', ['cooked']);
$workflow->save();
$this->assertFalse($workflow->getTypePlugin()->hasTransitionFromStateToState('fresh', 'rotten'));
$this->assertTrue($workflow->getTypePlugin()->hasTransitionFromStateToState('cooked', 'rotten'));
// Ensure the default configuration does not cause ordering issues.
$workflow->getTypePlugin()->addTransition('cook', 'Cook', ['fresh'], 'cooked');
$workflow->save();
$this->assertSame([
'cooked',
'fresh',
'rotten',
], array_keys($workflow->getTypePlugin()->getConfiguration()['states']));
$this->assertSame([
'cook',
'rot',
], array_keys($workflow->getTypePlugin()->getConfiguration()['transitions']));
// Ensure that transitions can be deleted.
$workflow->getTypePlugin()->deleteTransition('rot');
$workflow->save();
$this->assertFalse($workflow->getTypePlugin()->hasTransition('rot'));
}
}

View File

@ -0,0 +1,289 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workflows\Kernel;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Cache\Context\CacheContextsManager;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\KernelTests\KernelTestBase;
use Drupal\Tests\user\Traits\UserCreationTrait;
use Drupal\workflows\Entity\Workflow;
use Prophecy\Prophet;
/**
* @coversDefaultClass \Drupal\workflows\WorkflowAccessControlHandler
* @group workflows
* @group #slow
*/
class WorkflowAccessControlHandlerTest extends KernelTestBase {
use UserCreationTrait;
/**
* {@inheritdoc}
*/
protected static $modules = [
'workflows',
'workflow_type_test',
'system',
'user',
];
/**
* The workflow access control handler.
*
* @var \Drupal\workflows\WorkflowAccessControlHandler
*/
protected $accessControlHandler;
/**
* A test admin user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $adminUser;
/**
* A non-privileged user.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $user;
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->installEntitySchema('user');
$this->accessControlHandler = $this->container->get('entity_type.manager')->getAccessControlHandler('workflow');
// Create and discard user 1, which is special and bypasses all access
// checking.
$this->createUser([]);
$this->user = $this->createUser([]);
$this->adminUser = $this->createUser(['administer workflows']);
}
/**
* @covers ::checkCreateAccess
*/
public function testCheckCreateAccess(): void {
// A user must have the correct permission to create a workflow.
$this->assertEquals(
AccessResult::neutral()
->addCacheContexts(['user.permissions'])
->setReason("The 'administer workflows' permission is required."),
$this->accessControlHandler->createAccess(NULL, $this->user, [], TRUE)
);
$this->assertEquals(
AccessResult::allowed()
->addCacheContexts(['user.permissions']),
$this->accessControlHandler->createAccess(NULL, $this->adminUser, [], TRUE)
);
// Remove all plugin types and ensure not even the admin user is allowed to
// create a workflow.
$this->container->get('state')->set('workflow_type_test.plugin_definitions', []);
$this->container->get('plugin.manager.workflows.type')->clearCachedDefinitions();
$this->accessControlHandler->resetCache();
$this->assertEquals(
AccessResult::neutral()
->addCacheContexts(['user.permissions']),
$this->accessControlHandler->createAccess(NULL, $this->adminUser, [], TRUE)
);
}
/**
* @covers ::checkAccess
* @dataProvider checkAccessProvider
*/
public function testCheckAccess($user, $operation, $result, $states_to_create = []): void {
$workflow = Workflow::create([
'type' => 'workflow_type_test',
'id' => 'test_workflow',
'label' => 'Test workflow',
]);
$workflow->save();
$workflow_type = $workflow->getTypePlugin();
foreach ($states_to_create as $state_id => $is_required) {
$workflow_type->addState($state_id, $this->randomString());
}
\Drupal::state()->set('workflow_type_test.required_states', array_filter($states_to_create));
$this->assertEquals($result, $this->accessControlHandler->access($workflow, $operation, $this->{$user}, TRUE));
}
/**
* Data provider for ::testCheckAccess.
*
* @return array
* An array of test data.
*/
public static function checkAccessProvider() {
$originalContainer = \Drupal::hasContainer() ? \Drupal::getContainer() : NULL;
$container = new ContainerBuilder();
$cache_contexts_manager = (new Prophet())->prophesize(CacheContextsManager::class);
$cache_contexts_manager->assertValidTokens()->willReturn(TRUE);
$cache_contexts_manager->reveal();
$container->set('cache_contexts_manager', $cache_contexts_manager);
\Drupal::setContainer($container);
$data = [
'Admin view' => [
'adminUser',
'view',
AccessResult::allowed()->addCacheContexts(['user.permissions']),
],
'Admin update' => [
'adminUser',
'update',
AccessResult::allowed()->addCacheContexts(['user.permissions']),
],
'Admin delete' => [
'adminUser',
'delete',
AccessResult::allowed()->addCacheContexts(['user.permissions']),
],
'Admin delete only state' => [
'adminUser',
'delete-state:foo',
AccessResult::neutral()->addCacheTags(['config:workflows.workflow.test_workflow']),
['foo' => FALSE],
],
'Admin delete one of two states' => [
'adminUser',
'delete-state:foo',
AccessResult::allowed()
->addCacheTags(['config:workflows.workflow.test_workflow'])
->addCacheContexts(['user.permissions']),
['foo' => FALSE, 'bar' => FALSE],
],
'Admin delete required state when there are >1 states' => [
'adminUser',
'delete-state:foo',
AccessResult::allowed()
->addCacheTags(['config:workflows.workflow.test_workflow'])
->addCacheContexts(['user.permissions']),
['foo' => TRUE, 'bar' => FALSE],
],
'User view' => [
'user',
'view',
AccessResult::neutral()
->addCacheContexts(['user.permissions'])
->setReason("The 'administer workflows' permission is required."),
],
'User update' => [
'user',
'update',
AccessResult::neutral()
->addCacheContexts(['user.permissions'])
->setReason("The 'administer workflows' permission is required."),
],
'User delete' => [
'user',
'delete',
AccessResult::neutral()
->addCacheContexts(['user.permissions'])
->setReason("The 'administer workflows' permission is required."),
],
'User delete only state' => [
'user',
'delete-state:foo',
AccessResult::neutral()->addCacheTags(['config:workflows.workflow.test_workflow']),
['foo' => FALSE],
],
'User delete one of two states' => [
'user',
'delete-state:foo',
AccessResult::neutral()
->addCacheTags(['config:workflows.workflow.test_workflow'])
->addCacheContexts(['user.permissions'])
->setReason("The 'administer workflows' permission is required."),
['foo' => FALSE, 'bar' => FALSE],
],
'User delete required state when there are >1 states' => [
'user',
'delete-state:foo',
AccessResult::neutral()
->addCacheTags(['config:workflows.workflow.test_workflow'])
->addCacheContexts(['user.permissions'])
->setReason("The 'administer workflows' permission is required."),
['foo' => TRUE, 'bar' => FALSE],
],
'Update state for user, uses admin permission by default' => [
'user',
'update-state:foo',
AccessResult::neutral()
->addCacheContexts(['user.permissions'])
->setReason("The 'administer workflows' permission is required."),
],
'Update state for admin, uses admin permission by default' => [
'adminUser',
'update-state:foo',
AccessResult::allowed()->addCacheContexts(['user.permissions']),
],
'Add state for user, uses admin permission by default' => [
'user',
'add-state',
AccessResult::neutral()
->addCacheContexts(['user.permissions'])
->setReason("The 'administer workflows' permission is required."),
],
'Add state for admin, uses admin permission by default' => [
'adminUser',
'add-state',
AccessResult::allowed()->addCacheContexts(['user.permissions']),
],
'Add transition for user, uses admin permission by default' => [
'user',
'add-transition',
AccessResult::neutral()
->addCacheContexts(['user.permissions'])
->setReason("The 'administer workflows' permission is required."),
],
'Add transition for admin, uses admin permission by default' => [
'adminUser',
'add-transition',
AccessResult::allowed()->addCacheContexts(['user.permissions']),
],
'Edit transition for user, uses admin permission by default' => [
'user',
'edit-transition:foo',
AccessResult::neutral()
->addCacheContexts(['user.permissions'])
->setReason("The 'administer workflows' permission is required."),
],
'Edit transition for admin, uses admin permission by default' => [
'adminUser',
'edit-transition:foo',
AccessResult::allowed()->addCacheContexts(['user.permissions']),
],
'Delete transition for user, uses admin permission by default' => [
'user',
'delete-transition:foo',
AccessResult::neutral()
->addCacheContexts(['user.permissions'])
->setReason("The 'administer workflows' permission is required."),
],
'Delete transition for admin, uses admin permission by default' => [
'adminUser',
'delete-transition:foo',
AccessResult::allowed()->addCacheContexts(['user.permissions']),
],
];
// Restore the original container if needed.
if ($originalContainer) {
\Drupal::setContainer($originalContainer);
}
return $data;
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workflows\Kernel;
use Drupal\KernelTests\KernelTestBase;
use Drupal\workflows\Entity\Workflow;
/**
* Tests configuration dependencies in workflows.
*
* @coversDefaultClass \Drupal\workflows\Entity\Workflow
*
* @group workflows
*/
class WorkflowDependenciesTest extends KernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = [
'system',
'workflows',
'workflow_type_test',
'workflow_third_party_settings_test',
];
/**
* Tests \Drupal\workflows\Entity\Workflow::onDependencyRemoval().
*/
public function testOnDependencyRemoval(): void {
// Create a workflow that has a dependency on a third party setting.
$workflow = Workflow::create([
'id' => 'test3',
'label' => 'Test workflow',
'type' => 'workflow_type_complex_test',
]);
$workflow->setThirdPartySetting('workflow_third_party_settings_test', 'key', 'value');
$workflow->save();
$this->assertSame(['workflow_third_party_settings_test', 'workflow_type_test'], $workflow->getDependencies()['module']);
// Uninstall workflow_third_party_settings_test to ensure
// \Drupal\workflows\Entity\Workflow::onDependencyRemoval() works as
// expected.
\Drupal::service('module_installer')->uninstall(['node', 'workflow_third_party_settings_test']);
/** @var \Drupal\workflows\WorkflowInterface $workflow */
$workflow = \Drupal::entityTypeManager()->getStorage('workflow')->loadUnchanged($workflow->id());
$this->assertSame(['workflow_type_test'], $workflow->getDependencies()['module']);
}
}

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workflows\Kernel;
use Drupal\KernelTests\Core\Config\ConfigEntityValidationTestBase;
use Drupal\workflows\Entity\Workflow;
/**
* Tests validation of workflow entities.
*
* @group workflows
*/
class WorkflowValidationTest extends ConfigEntityValidationTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['workflows', 'workflow_type_test'];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->entity = Workflow::create([
'id' => 'test',
'label' => 'Test',
'type' => 'workflow_type_test',
]);
$this->entity->save();
}
/**
* Tests that the workflow type plugin is validated.
*/
public function testTypePluginIsValidated(): void {
$this->entity->set('type', 'non_existent');
$this->assertValidationErrors([
'type' => "The 'non_existent' plugin does not exist.",
]);
}
}

View File

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workflows\Unit;
use Drupal\Tests\UnitTestCase;
use Drupal\workflow_type_test\Plugin\WorkflowType\TestType;
use Drupal\workflows\State;
use Drupal\workflows\WorkflowTypeInterface;
/**
* @coversDefaultClass \Drupal\workflows\State
*
* @group workflows
*/
class StateTest extends UnitTestCase {
/**
* @covers ::__construct
* @covers ::id
* @covers ::label
* @covers ::weight
*/
public function testGetters(): void {
$state = new State(
$this->prophesize(WorkflowTypeInterface::class)->reveal(),
'draft',
'Draft',
3
);
$this->assertEquals('draft', $state->id());
$this->assertEquals('Draft', $state->label());
$this->assertEquals(3, $state->weight());
}
/**
* @covers ::canTransitionTo
*/
public function testCanTransitionTo(): void {
$workflow_type = new TestType([], '', []);
$workflow_type
->addState('draft', 'Draft')
->addState('published', 'Published')
->addTransition('publish', 'Publish', ['draft'], 'published');
$state = $workflow_type->getState('draft');
$this->assertTrue($state->canTransitionTo('published'));
$this->assertFalse($state->canTransitionTo('some_other_state'));
$workflow_type->deleteTransition('publish');
$this->assertFalse($state->canTransitionTo('published'));
}
/**
* @covers ::getTransitionTo
*/
public function testGetTransitionTo(): void {
$workflow_type = new TestType([], '', []);
$workflow_type
->addState('draft', 'Draft')
->addState('published', 'Published')
->addTransition('publish', 'Publish', ['draft'], 'published');
$state = $workflow_type->getState('draft');
$transition = $state->getTransitionTo('published');
$this->assertEquals('Publish', $transition->label());
}
/**
* @covers ::getTransitionTo
*/
public function testGetTransitionToException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("Can not transition to 'published' state");
$workflow_type = new TestType([], '', []);
$workflow_type->addState('draft', 'Draft');
$state = $workflow_type->getState('draft');
$state->getTransitionTo('published');
}
/**
* @covers ::getTransitions
*/
public function testGetTransitions(): void {
$workflow_type = new TestType([], '', []);
$workflow_type
->addState('draft', 'Draft')
->addState('published', 'Published')
->addState('archived', 'Archived')
->addTransition('create_new_draft', 'Create new draft', ['draft'], 'draft')
->addTransition('publish', 'Publish', ['draft'], 'published')
->addTransition('archive', 'Archive', ['published'], 'archived');
$state = $workflow_type->getState('draft');
$transitions = $state->getTransitions();
$this->assertCount(2, $transitions);
$this->assertEquals('Create new draft', $transitions['create_new_draft']->label());
$this->assertEquals('Publish', $transitions['publish']->label());
}
/**
* @covers ::labelCallback
*/
public function testLabelCallback(): void {
$workflow_type = $this->prophesize(WorkflowTypeInterface::class)->reveal();
$states = [
new State($workflow_type, 'draft', 'Draft'),
new State($workflow_type, 'published', 'Published'),
];
$this->assertEquals(['Draft', 'Published'], array_map([State::class, 'labelCallback'], $states));
}
}

View File

@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workflows\Unit;
use Drupal\Tests\UnitTestCase;
use Drupal\workflow_type_test\Plugin\WorkflowType\TestType;
use Drupal\workflows\Transition;
use Drupal\workflows\WorkflowTypeInterface;
/**
* @coversDefaultClass \Drupal\workflows\Transition
*
* @group workflows
*/
class TransitionTest extends UnitTestCase {
/**
* @covers ::__construct
* @covers ::id
* @covers ::label
*/
public function testGetters(): void {
$state = new Transition(
$this->prophesize(WorkflowTypeInterface::class)->reveal(),
'draft_published',
'Publish',
['draft'],
'published'
);
$this->assertEquals('draft_published', $state->id());
$this->assertEquals('Publish', $state->label());
}
/**
* @covers ::from
* @covers ::to
*/
public function testFromAndTo(): void {
$workflow = new TestType([], '', []);
$workflow
->addState('draft', 'Draft')
->addState('published', 'Published')
->addTransition('publish', 'Publish', ['draft'], 'published');
$state = $workflow->getState('draft');
$transition = $state->getTransitionTo('published');
$this->assertEquals($state, $transition->from()['draft']);
$this->assertEquals($workflow->getState('published'), $transition->to());
}
}

View File

@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workflows\Unit;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Routing\RouteMatch;
use Drupal\Core\Session\AccountInterface;
use Drupal\Tests\UnitTestCase;
use Drupal\workflows\WorkflowStateTransitionOperationsAccessCheck;
use Drupal\workflows\WorkflowInterface;
use Prophecy\Argument;
use Symfony\Component\Routing\Route;
/**
* @coversDefaultClass \Drupal\workflows\WorkflowStateTransitionOperationsAccessCheck
* @group workflows
*/
class WorkflowStateTransitionOperationsAccessCheckTest extends UnitTestCase {
/**
* Tests the access method correctly proxies to the entity access system.
*
* @covers ::access
* @dataProvider accessTestCases
*/
public function testAccess($route_requirement, $resulting_entity_access_check, $route_parameters = []): void {
$workflow_entity_access_result = AccessResult::allowed();
$workflow = $this->prophesize(WorkflowInterface::class);
$workflow->access($resulting_entity_access_check, Argument::type(AccountInterface::class), TRUE)
->shouldBeCalled()
->willReturn($workflow_entity_access_result);
$route = new Route('', [
'workflow' => NULL,
'workflow_transition' => NULL,
'workflow_state' => NULL,
], [
'_workflow_access' => $route_requirement,
]);
$route_match_params = ['workflow' => $workflow->reveal()] + $route_parameters;
$route_match = new RouteMatch(NULL, $route, $route_match_params);
$access_check = new WorkflowStateTransitionOperationsAccessCheck();
$account = $this->prophesize(AccountInterface::class);
$this->assertEquals($workflow_entity_access_result, $access_check->access($route_match, $account->reveal()));
}
/**
* Test cases for ::testAccess.
*/
public static function accessTestCases() {
return [
'Transition add' => [
'add-transition',
'add-transition',
],
'Transition update' => [
'update-transition',
'update-transition:foo-transition',
[
'workflow_transition' => 'foo-transition',
],
],
'Transition delete' => [
'delete-transition',
'delete-transition:foo-transition',
[
'workflow_transition' => 'foo-transition',
],
],
'State add' => [
'add-state',
'add-state',
],
'State update' => [
'update-state',
'update-state:bar-state',
[
'workflow_state' => 'bar-state',
],
],
'State delete' => [
'delete-state',
'delete-state:bar-state',
[
'workflow_state' => 'bar-state',
],
],
];
}
/**
* @covers ::access
*/
public function testMissingRouteParams(): void {
$workflow = $this->prophesize(WorkflowInterface::class);
$workflow->access()->shouldNotBeCalled();
$route = new Route('', [
'workflow' => NULL,
'workflow_state' => NULL,
], [
'_workflow_access' => 'update-state',
]);
$access_check = new WorkflowStateTransitionOperationsAccessCheck();
$account = $this->prophesize(AccountInterface::class);
$missing_both = new RouteMatch(NULL, $route, []);
$this->assertEquals(AccessResult::neutral(), $access_check->access($missing_both, $account->reveal()));
$missing_state = new RouteMatch(NULL, $route, [
'workflow' => $workflow->reveal(),
]);
$this->assertEquals(AccessResult::neutral(), $access_check->access($missing_state, $account->reveal()));
$missing_workflow = new RouteMatch(NULL, $route, [
'workflow_state' => 'foo',
]);
$this->assertEquals(AccessResult::neutral(), $access_check->access($missing_workflow, $account->reveal()));
}
/**
* @covers ::access
* @dataProvider invalidOperationNameTestCases
*/
public function testInvalidOperationName($operation_name): void {
$this->expectException(\Exception::class);
$this->expectExceptionMessage("Invalid _workflow_access operation '$operation_name' specified for route 'Foo Route'.");
$route = new Route('', [], [
'_workflow_access' => $operation_name,
]);
$access_check = new WorkflowStateTransitionOperationsAccessCheck();
$account = $this->prophesize(AccountInterface::class);
$access_check->access(new RouteMatch('Foo Route', $route, []), $account->reveal());
}
/**
* Test cases for ::testInvalidOperationName.
*/
public static function invalidOperationNameTestCases() {
return [
['invalid-op'],
['foo-add-transition'],
['add-transition-bar'],
];
}
}

View File

@ -0,0 +1,712 @@
<?php
declare(strict_types=1);
namespace Drupal\Tests\workflows\Unit;
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Tests\UnitTestCase;
use Drupal\workflow_type_test\Plugin\WorkflowType\TestType;
use Drupal\workflows\Entity\Workflow;
use Drupal\workflows\State;
use Drupal\workflows\Transition;
use Drupal\workflows\WorkflowTypeManager;
use Prophecy\Argument;
/**
* @coversDefaultClass \Drupal\workflows\Plugin\WorkflowTypeBase
*
* @group workflows
*/
class WorkflowTest extends UnitTestCase {
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
// Create a container so that the plugin manager and workflow type can be
// mocked.
$container = new ContainerBuilder();
$workflow_manager = $this->prophesize(WorkflowTypeManager::class);
$workflow_manager->createInstance('test_type', Argument::any())->willReturn(new TestType([], '', []));
$container->set('plugin.manager.workflows.type', $workflow_manager->reveal());
\Drupal::setContainer($container);
}
/**
* @covers ::addState
* @covers ::hasState
*/
public function testAddAndHasState(): void {
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$this->assertFalse($workflow->getTypePlugin()->hasState('draft'));
// By default states are ordered in the order added.
$workflow->getTypePlugin()->addState('draft', 'Draft');
$this->assertTrue($workflow->getTypePlugin()->hasState('draft'));
$this->assertFalse($workflow->getTypePlugin()->hasState('published'));
$this->assertEquals(0, $workflow->getTypePlugin()->getState('draft')->weight());
// Adding a state does not set up a transition to itself.
$this->assertFalse($workflow->getTypePlugin()->hasTransitionFromStateToState('draft', 'draft'));
// New states are added with a new weight 1 more than the current highest
// weight.
$workflow->getTypePlugin()->addState('published', 'Published');
$this->assertEquals(1, $workflow->getTypePlugin()->getState('published')->weight());
}
/**
* @covers ::addState
*/
public function testAddStateException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The state 'draft' already exists in workflow.");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow->getTypePlugin()->addState('draft', 'Draft');
$workflow->getTypePlugin()->addState('draft', 'Draft');
}
/**
* @covers ::addState
*/
public function testAddStateInvalidIdException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The state ID 'draft-draft' must contain only lowercase letters, numbers, and underscores");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow->getTypePlugin()->addState('draft-draft', 'Draft');
}
/**
* @covers ::getStates
*/
public function testGetStates(): void {
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
// Getting states works when there are none.
$this->assertSame([], $workflow->getTypePlugin()->getStates());
$this->assertSame([], $workflow->getTypePlugin()->getStates([]));
$workflow
->getTypePlugin()
->addState('draft', 'Draft')
->addState('published', 'Published')
->addState('archived', 'Archived');
// States are stored in alphabetical key order.
$this->assertEquals([
'archived',
'draft',
'published',
], array_keys($workflow->getTypePlugin()->getConfiguration()['states']));
// Ensure we're returning state objects.
$this->assertInstanceOf(State::class, $workflow->getTypePlugin()->getStates()['draft']);
// Passing in no IDs returns all states.
$this->assertEquals(['draft', 'published', 'archived'], array_keys($workflow->getTypePlugin()->getStates()));
// The order of states is by weight.
$workflow->getTypePlugin()->setStateWeight('published', -1);
$this->assertEquals(['published', 'draft', 'archived'], array_keys($workflow->getTypePlugin()->getStates()));
// The label is also used for sorting if weights are equal.
$workflow->getTypePlugin()->setStateWeight('archived', 0);
$this->assertEquals(['published', 'archived', 'draft'], array_keys($workflow->getTypePlugin()->getStates()));
// You can limit the states returned by passing in states IDs.
$this->assertEquals(['archived', 'draft'], array_keys($workflow->getTypePlugin()->getStates(['draft', 'archived'])));
// An empty array does not load all states.
$this->assertSame([], $workflow->getTypePlugin()->getStates([]));
}
/**
* Tests numeric IDs when added to a workflow.
*/
public function testNumericIdSorting(): void {
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow_type = $workflow->getTypePlugin();
$workflow_type->addState('1', 'One');
$workflow_type->addState('2', 'Two');
$workflow_type->addState('3', 'ZZZ');
$workflow_type->addState('4', 'AAA');
$workflow_type->setStateWeight('1', 1);
$workflow_type->setStateWeight('2', 2);
$workflow_type->setStateWeight('3', 3);
$workflow_type->setStateWeight('4', 3);
// Ensure numeric states are correctly sorted by weight first, label second.
$this->assertEquals([1, 2, 4, 3], array_keys($workflow_type->getStates()));
}
/**
* @covers ::getStates
*/
public function testGetStatesException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The state 'state_that_does_not_exist' does not exist in workflow.");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow->getTypePlugin()->getStates(['state_that_does_not_exist']);
}
/**
* @covers ::getState
*/
public function testGetState(): void {
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
// By default states are ordered in the order added.
$workflow
->getTypePlugin()
->addState('draft', 'Draft')
->addState('published', 'Published')
->addState('archived', 'Archived')
->addTransition('create_new_draft', 'Create new draft', ['draft'], 'draft')
->addTransition('publish', 'Publish', ['draft'], 'published');
// Ensure we're returning state objects and they are set up correctly
$this->assertInstanceOf(State::class, $workflow->getTypePlugin()->getState('draft'));
$this->assertEquals('archived', $workflow->getTypePlugin()->getState('archived')->id());
$this->assertEquals('Archived', $workflow->getTypePlugin()->getState('archived')->label());
$draft = $workflow->getTypePlugin()->getState('draft');
$this->assertTrue($draft->canTransitionTo('draft'));
$this->assertTrue($draft->canTransitionTo('published'));
$this->assertFalse($draft->canTransitionTo('archived'));
$this->assertEquals('Publish', $draft->getTransitionTo('published')->label());
$this->assertEquals(0, $draft->weight());
$this->assertEquals(1, $workflow->getTypePlugin()->getState('published')->weight());
$this->assertEquals(2, $workflow->getTypePlugin()->getState('archived')->weight());
}
/**
* @covers ::getState
*/
public function testGetStateException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The state 'state_that_does_not_exist' does not exist in workflow.");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow->getTypePlugin()->getState('state_that_does_not_exist');
}
/**
* @covers ::setStateLabel
*/
public function testSetStateLabel(): void {
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow->getTypePlugin()->addState('draft', 'Draft');
$this->assertEquals('Draft', $workflow->getTypePlugin()->getState('draft')->label());
$workflow->getTypePlugin()->setStateLabel('draft', 'Unpublished');
$this->assertEquals('Unpublished', $workflow->getTypePlugin()->getState('draft')->label());
}
/**
* @covers ::setStateLabel
*/
public function testSetStateLabelException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The state 'draft' does not exist in workflow.");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow->getTypePlugin()->setStateLabel('draft', 'Draft');
}
/**
* @covers ::setStateWeight
*/
public function testSetStateWeight(): void {
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow->getTypePlugin()->addState('draft', 'Draft');
$this->assertEquals(0, $workflow->getTypePlugin()->getState('draft')->weight());
$workflow->getTypePlugin()->setStateWeight('draft', -10);
$this->assertEquals(-10, $workflow->getTypePlugin()->getState('draft')->weight());
}
/**
* @covers ::setStateWeight
*/
public function testSetStateWeightException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The state 'draft' does not exist in workflow.");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow->getTypePlugin()->setStateWeight('draft', 10);
}
/**
* @covers ::setStateWeight
*/
public function testSetStateWeightNonNumericException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The weight 'foo' must be numeric for state 'Published'.");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow->getTypePlugin()->addState('published', 'Published');
$workflow->getTypePlugin()->setStateWeight('published', 'foo');
}
/**
* @covers ::deleteState
*/
public function testDeleteState(): void {
$workflow_type = new TestType([], '', []);
$workflow_type
->addState('draft', 'Draft')
->addState('published', 'Published')
->addState('archived', 'Archived')
->addTransition('publish', 'Publish', ['draft', 'published'], 'published')
->addTransition('create_new_draft', 'Create new draft', ['draft', 'published'], 'draft')
->addTransition('archive', 'Archive', ['draft', 'published'], 'archived');
$this->assertCount(3, $workflow_type->getStates());
$this->assertCount(3, $workflow_type->getState('published')->getTransitions());
$workflow_type->deleteState('draft');
$this->assertFalse($workflow_type->hasState('draft'));
$this->assertCount(2, $workflow_type->getStates());
$this->assertCount(2, $workflow_type->getState('published')->getTransitions());
$workflow_type->deleteState('published');
$this->assertCount(0, $workflow_type->getTransitions());
}
/**
* @covers ::deleteState
*/
public function testDeleteStateException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The state 'draft' does not exist in workflow.");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow->getTypePlugin()->deleteState('draft');
}
/**
* @covers ::deleteState
*/
public function testDeleteOnlyStateException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The state 'draft' can not be deleted from workflow as it is the only state");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow->getTypePlugin()->addState('draft', 'Draft');
$workflow->getTypePlugin()->deleteState('draft');
}
/**
* @covers ::addTransition
* @covers ::hasTransition
*/
public function testAddTransition(): void {
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
// By default states are ordered in the order added.
$workflow
->getTypePlugin()
->addState('draft', 'Draft')
->addState('published', 'Published');
$this->assertFalse($workflow->getTypePlugin()->getState('draft')->canTransitionTo('published'));
$workflow->getTypePlugin()->addTransition('publish', 'Publish', ['draft'], 'published');
$this->assertTrue($workflow->getTypePlugin()->getState('draft')->canTransitionTo('published'));
$this->assertEquals(0, $workflow->getTypePlugin()->getTransition('publish')->weight());
$this->assertTrue($workflow->getTypePlugin()->hasTransition('publish'));
$this->assertFalse($workflow->getTypePlugin()->hasTransition('draft'));
$workflow->getTypePlugin()->addTransition('save_publish', 'Save', ['published'], 'published');
$this->assertEquals(1, $workflow->getTypePlugin()->getTransition('save_publish')->weight());
}
/**
* @covers ::addTransition
*/
public function testAddTransitionDuplicateException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The transition 'publish' already exists in workflow.");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow->getTypePlugin()->addState('published', 'Published');
$workflow->getTypePlugin()->addTransition('publish', 'Publish', ['published'], 'published');
$workflow->getTypePlugin()->addTransition('publish', 'Publish', ['published'], 'published');
}
/**
* @covers ::addTransition
*/
public function testAddTransitionInvalidIdException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The transition ID 'publish-publish' must contain only lowercase letters, numbers, and underscores");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow->getTypePlugin()->addState('published', 'Published');
$workflow->getTypePlugin()->addTransition('publish-publish', 'Publish', ['published'], 'published');
}
/**
* @covers ::addTransition
*/
public function testAddTransitionMissingFromException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The state 'draft' does not exist in workflow.");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow->getTypePlugin()->addState('published', 'Published');
$workflow->getTypePlugin()->addTransition('publish', 'Publish', ['draft'], 'published');
}
/**
* @covers ::addTransition
*/
public function testAddTransitionDuplicateTransitionStatesException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The 'publish' transition already allows 'draft' to 'published' transitions in workflow.");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow
->getTypePlugin()
->addState('draft', 'Draft')
->addState('published', 'Published');
$workflow->getTypePlugin()->addTransition('publish', 'Publish', ['draft', 'published'], 'published');
$workflow->getTypePlugin()->addTransition('draft_to_published', 'Publish a draft', ['draft'], 'published');
}
/**
* @covers ::addTransition
*/
public function testAddTransitionConsistentAfterFromCatch(): void {
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow->getTypePlugin()->addState('published', 'Published');
try {
$workflow->getTypePlugin()->addTransition('publish', 'Publish', ['draft'], 'published');
}
catch (\InvalidArgumentException) {
}
// Ensure that the workflow is not left in an inconsistent state after an
// exception is thrown from Workflow::setTransitionFromStates() whilst
// calling Workflow::addTransition().
$this->assertFalse($workflow->getTypePlugin()->hasTransition('publish'));
}
/**
* @covers ::addTransition
*/
public function testAddTransitionMissingToException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The state 'published' does not exist in workflow.");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow->getTypePlugin()->addState('draft', 'Draft');
$workflow->getTypePlugin()->addTransition('publish', 'Publish', ['draft'], 'published');
}
/**
* @covers ::getTransitions
* @covers ::setTransitionWeight
*/
public function testGetTransitions(): void {
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
// Getting transitions works when there are none.
$this->assertSame([], $workflow->getTypePlugin()->getTransitions());
$this->assertSame([], $workflow->getTypePlugin()->getTransitions([]));
// By default states are ordered in the order added.
$workflow
->getTypePlugin()
->addState('a', 'A')
->addState('b', 'B')
->addTransition('a_b', 'A to B', ['a'], 'b')
->addTransition('a_a', 'A to A', ['a'], 'a');
// Transitions are stored in alphabetical key order in configuration.
$this->assertEquals(['a_a', 'a_b'], array_keys($workflow->getTypePlugin()->getConfiguration()['transitions']));
// Ensure we're returning transition objects.
$this->assertInstanceOf(Transition::class, $workflow->getTypePlugin()->getTransitions()['a_a']);
// Passing in no IDs returns all transitions.
$this->assertEquals(['a_b', 'a_a'], array_keys($workflow->getTypePlugin()->getTransitions()));
// The order of states is by weight.
$workflow->getTypePlugin()->setTransitionWeight('a_a', -1);
$this->assertEquals(['a_a', 'a_b'], array_keys($workflow->getTypePlugin()->getTransitions()));
// If all weights are equal it will fallback to labels.
$workflow->getTypePlugin()->setTransitionWeight('a_a', 0);
$this->assertEquals(['a_a', 'a_b'], array_keys($workflow->getTypePlugin()->getTransitions()));
$workflow->getTypePlugin()->setTransitionLabel('a_b', 'A B');
$this->assertEquals(['a_b', 'a_a'], array_keys($workflow->getTypePlugin()->getTransitions()));
// You can limit the states returned by passing in states IDs.
$this->assertEquals(['a_a'], array_keys($workflow->getTypePlugin()->getTransitions(['a_a'])));
// An empty array does not load all states.
$this->assertSame([], $workflow->getTypePlugin()->getTransitions([]));
}
/**
* @covers ::getTransition
*/
public function testGetTransition(): void {
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
// By default states are ordered in the order added.
$workflow
->getTypePlugin()
->addState('draft', 'Draft')
->addState('published', 'Published')
->addState('archived', 'Archived')
->addTransition('create_new_draft', 'Create new draft', ['draft'], 'draft')
->addTransition('publish', 'Publish', ['draft'], 'published');
// Ensure we're returning state objects and they are set up correctly
$this->assertInstanceOf(Transition::class, $workflow->getTypePlugin()->getTransition('create_new_draft'));
$this->assertEquals('publish', $workflow->getTypePlugin()->getTransition('publish')->id());
$this->assertEquals('Publish', $workflow->getTypePlugin()->getTransition('publish')->label());
$transition = $workflow->getTypePlugin()->getTransition('publish');
$this->assertEquals($workflow->getTypePlugin()->getState('draft'), $transition->from()['draft']);
$this->assertEquals($workflow->getTypePlugin()->getState('published'), $transition->to());
}
/**
* @covers ::getTransition
*/
public function testGetTransitionException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The transition 'transition_that_does_not_exist' does not exist in workflow.");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow->getTypePlugin()->getTransition('transition_that_does_not_exist');
}
/**
* @covers ::getTransitionsForState
*/
public function testGetTransitionsForState(): void {
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
// By default states are ordered in the order added.
$workflow
->getTypePlugin()
->addState('draft', 'Draft')
->addState('published', 'Published')
->addState('archived', 'Archived')
->addTransition('create_new_draft', 'Create new draft', ['archived', 'draft'], 'draft')
->addTransition('publish', 'Publish', ['draft', 'published'], 'published')
->addTransition('archive', 'Archive', ['published'], 'archived');
$this->assertEquals(['create_new_draft', 'publish'], array_keys($workflow->getTypePlugin()->getTransitionsForState('draft')));
$this->assertEquals(['create_new_draft'], array_keys($workflow->getTypePlugin()->getTransitionsForState('draft', 'to')));
$this->assertEquals(['publish', 'archive'], array_keys($workflow->getTypePlugin()->getTransitionsForState('published')));
$this->assertEquals(['publish'], array_keys($workflow->getTypePlugin()->getTransitionsForState('published', 'to')));
$this->assertEquals(['create_new_draft'], array_keys($workflow->getTypePlugin()->getTransitionsForState('archived', 'from')));
$this->assertEquals(['archive'], array_keys($workflow->getTypePlugin()->getTransitionsForState('archived', 'to')));
}
/**
* @covers ::getTransitionFromStateToState
* @covers ::hasTransitionFromStateToState
*/
public function testGetTransitionFromStateToState(): void {
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
// By default states are ordered in the order added.
$workflow
->getTypePlugin()
->addState('draft', 'Draft')
->addState('published', 'Published')
->addState('archived', 'Archived')
->addTransition('create_new_draft', 'Create new draft', ['archived', 'draft'], 'draft')
->addTransition('publish', 'Publish', ['draft', 'published'], 'published')
->addTransition('archive', 'Archive', ['published'], 'archived');
$this->assertTrue($workflow->getTypePlugin()->hasTransitionFromStateToState('draft', 'published'));
$this->assertFalse($workflow->getTypePlugin()->hasTransitionFromStateToState('archived', 'archived'));
$transition = $workflow->getTypePlugin()->getTransitionFromStateToState('published', 'archived');
$this->assertEquals('Archive', $transition->label());
}
/**
* @covers ::getTransitionFromStateToState
*/
public function testGetTransitionFromStateToStateException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The transition from 'archived' to 'archived' does not exist in workflow.");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
// By default states are ordered in the order added.
$workflow
->getTypePlugin()
->addState('draft', 'Draft')
->addState('published', 'Published')
->addState('archived', 'Archived')
->addTransition('create_new_draft', 'Create new draft', ['archived', 'draft'], 'draft')
->addTransition('publish', 'Publish', ['draft', 'published'], 'published')
->addTransition('archive', 'Archive', ['published'], 'archived');
$workflow->getTypePlugin()->getTransitionFromStateToState('archived', 'archived');
}
/**
* @covers ::setTransitionLabel
*/
public function testSetTransitionLabel(): void {
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow
->getTypePlugin()
->addState('draft', 'Draft')
->addState('published', 'Published')
->addTransition('publish', 'Publish', ['draft'], 'published');
$this->assertEquals('Publish', $workflow->getTypePlugin()->getTransition('publish')->label());
$workflow->getTypePlugin()->setTransitionLabel('publish', 'Publish!');
$this->assertEquals('Publish!', $workflow->getTypePlugin()->getTransition('publish')->label());
}
/**
* @covers ::setTransitionLabel
*/
public function testSetTransitionLabelException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The transition 'draft-published' does not exist in workflow.");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow->getTypePlugin()->addState('published', 'Published');
$workflow->getTypePlugin()->setTransitionLabel('draft-published', 'Publish');
}
/**
* @covers ::setTransitionWeight
*/
public function testSetTransitionWeight(): void {
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow
->getTypePlugin()
->addState('draft', 'Draft')
->addState('published', 'Published')
->addTransition('publish', 'Publish', ['draft'], 'published');
$this->assertEquals(0, $workflow->getTypePlugin()->getTransition('publish')->weight());
$workflow->getTypePlugin()->setTransitionWeight('publish', 10);
$this->assertEquals(10, $workflow->getTypePlugin()->getTransition('publish')->weight());
}
/**
* @covers ::setTransitionWeight
*/
public function testSetTransitionWeightException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The transition 'draft-published' does not exist in workflow.");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow->getTypePlugin()->addState('published', 'Published');
$workflow->getTypePlugin()->setTransitionWeight('draft-published', 10);
}
/**
* @covers ::setTransitionWeight
*/
public function testSetTransitionWeightNonNumericException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The weight 'foo' must be numeric for transition 'Publish'.");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow->getTypePlugin()->addState('published', 'Published');
$workflow->getTypePlugin()->addTransition('publish', 'Publish', [], 'published');
$workflow->getTypePlugin()->setTransitionWeight('publish', 'foo');
}
/**
* @covers ::setTransitionFromStates
*/
public function testSetTransitionFromStates(): void {
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow
->getTypePlugin()
->addState('draft', 'Draft')
->addState('published', 'Published')
->addState('archived', 'Archived')
->addTransition('test', 'Test', ['draft'], 'draft');
$this->assertTrue($workflow->getTypePlugin()->hasTransitionFromStateToState('draft', 'draft'));
$this->assertFalse($workflow->getTypePlugin()->hasTransitionFromStateToState('published', 'draft'));
$this->assertFalse($workflow->getTypePlugin()->hasTransitionFromStateToState('archived', 'draft'));
$workflow->getTypePlugin()->setTransitionFromStates('test', ['draft', 'published', 'archived']);
$this->assertTrue($workflow->getTypePlugin()->hasTransitionFromStateToState('draft', 'draft'));
$this->assertTrue($workflow->getTypePlugin()->hasTransitionFromStateToState('published', 'draft'));
$this->assertTrue($workflow->getTypePlugin()->hasTransitionFromStateToState('archived', 'draft'));
$workflow->getTypePlugin()->setTransitionFromStates('test', ['published', 'archived']);
$this->assertFalse($workflow->getTypePlugin()->hasTransitionFromStateToState('draft', 'draft'));
$this->assertTrue($workflow->getTypePlugin()->hasTransitionFromStateToState('published', 'draft'));
$this->assertTrue($workflow->getTypePlugin()->hasTransitionFromStateToState('archived', 'draft'));
}
/**
* @covers ::setTransitionFromStates
*/
public function testSetTransitionFromStatesMissingTransition(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The transition 'test' does not exist in workflow.");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow
->getTypePlugin()
->addState('draft', 'Draft')
->addState('published', 'Published')
->addState('archived', 'Archived')
->addTransition('create_new_draft', 'Create new draft', ['draft'], 'draft');
$workflow->getTypePlugin()->setTransitionFromStates('test', ['draft', 'published', 'archived']);
}
/**
* @covers ::setTransitionFromStates
*/
public function testSetTransitionFromStatesMissingState(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The state 'published' does not exist in workflow.");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow
->getTypePlugin()
->addState('draft', 'Draft')
->addState('archived', 'Archived')
->addTransition('create_new_draft', 'Create new draft', ['draft'], 'draft');
$workflow->getTypePlugin()->setTransitionFromStates('create_new_draft', ['draft', 'published', 'archived']);
}
/**
* @covers ::setTransitionFromStates
*/
public function testSetTransitionFromStatesAlreadyExists(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The 'create_new_draft' transition already allows 'draft' to 'draft' transitions in workflow.");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow
->getTypePlugin()
->addState('draft', 'Draft')
->addState('archived', 'Archived')
->addState('needs_review', 'Needs Review')
->addTransition('create_new_draft', 'Create new draft', ['draft'], 'draft')
->addTransition('needs_review', 'Needs review', ['needs_review'], 'draft');
$workflow->getTypePlugin()->setTransitionFromStates('needs_review', ['draft']);
}
/**
* @covers ::deleteTransition
*/
public function testDeleteTransition(): void {
$workflow_type = new TestType([], '', []);
$workflow_type
->addState('draft', 'Draft')
->addState('published', 'Published')
->addTransition('create_new_draft', 'Create new draft', ['draft'], 'draft')
->addTransition('publish', 'Publish', ['draft'], 'published');
$this->assertTrue($workflow_type->getState('draft')->canTransitionTo('published'));
$workflow_type->deleteTransition('publish');
$this->assertFalse($workflow_type->getState('draft')->canTransitionTo('published'));
$this->assertTrue($workflow_type->getState('draft')->canTransitionTo('draft'));
}
/**
* @covers ::deleteTransition
*/
public function testDeleteTransitionException(): void {
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage("The transition 'draft-published' does not exist in workflow.");
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$workflow->getTypePlugin()->addState('published', 'Published');
$workflow->getTypePlugin()->deleteTransition('draft-published');
}
/**
* @covers \Drupal\workflows\Entity\Workflow::status
*/
public function testStatus(): void {
$workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow');
$this->assertFalse($workflow->status());
$workflow->getTypePlugin()->addState('published', 'Published');
$this->assertTrue($workflow->status());
}
}

View File

@ -0,0 +1,18 @@
<?php
/**
* @file
* API documentation for Workflows module.
*/
/**
* @defgroup workflow_type_plugins Workflow Type Plugins
* @{
* Any module harnessing the Workflows module must define a Workflow Type
* Plugin. This allows the module to tailor the workflow to its specific need.
* For example, the Content Moderation module uses its Workflow Type Plugin to
* link workflows to entities.
* On their own, workflows are a stand-alone concept. It takes a module such as
* Content Moderation to give the workflow context.
* @}
*/

View File

@ -0,0 +1,6 @@
name: 'Workflows'
type: module
description: 'Provides an interface to create workflows with transitions between different states (for example publication or user status) provided by other modules.'
version: VERSION
package: Core
configure: entity.workflow.collection

View File

@ -0,0 +1,8 @@
# Workflows extension relation types.
# See https://tools.ietf.org/html/rfc5988#section-4.2.
add-state-form:
uri: https://drupal.org/link-relations/add-state-form
description: A form where a state can be created.
add-transition-form:
uri: https://drupal.org/link-relations/add-transition-form
description: A form where a transition can be created.

View File

@ -0,0 +1,5 @@
entity.workflow.add_form:
route_name: 'entity.workflow.add_form'
title: 'Add workflow'
appears_on:
- entity.workflow.collection

View File

@ -0,0 +1,5 @@
entity.workflow.collection:
title: 'Workflows'
route_name: entity.workflow.collection
description: 'Configure workflows.'
parent: system.admin_config_workflow

View File

@ -0,0 +1,4 @@
administer workflows:
title: 'Administer workflows'
description: 'Create and edit workflows.'
restrict access: TRUE

View File

@ -0,0 +1,47 @@
entity.workflow.add_state_form:
path: '/admin/config/workflow/workflows/manage/{workflow}/add_state'
defaults:
_entity_form: 'workflow.add-state'
_title: 'Add state'
requirements:
_workflow_access: 'add-state'
entity.workflow.edit_state_form:
path: '/admin/config/workflow/workflows/manage/{workflow}/state/{workflow_state}'
defaults:
_entity_form: 'workflow.edit-state'
_title: 'Edit state'
requirements:
_workflow_access: 'update-state'
entity.workflow.delete_state_form:
path: '/admin/config/workflow/workflows/manage/{workflow}/state/{workflow_state}/delete'
defaults:
_form: '\Drupal\workflows\Form\WorkflowStateDeleteForm'
_title: 'Delete state'
requirements:
_workflow_access: 'delete-state'
entity.workflow.add_transition_form:
path: '/admin/config/workflow/workflows/manage/{workflow}/add_transition'
defaults:
_entity_form: 'workflow.add-transition'
_title: 'Add transition'
requirements:
_workflow_access: 'add-transition'
entity.workflow.edit_transition_form:
path: '/admin/config/workflow/workflows/manage/{workflow}/transition/{workflow_transition}'
defaults:
_entity_form: 'workflow.edit-transition'
_title: 'Edit transition'
requirements:
_workflow_access: 'update-transition'
entity.workflow.delete_transition_form:
path: '/admin/config/workflow/workflows/manage/{workflow}/transition/{workflow_transition}/delete'
defaults:
_form: '\Drupal\workflows\Form\WorkflowTransitionDeleteForm'
_title: 'Delete transition'
requirements:
_workflow_access: 'delete-transition'

View File

@ -0,0 +1,15 @@
parameters:
workflows.skip_procedural_hook_scan: true
services:
_defaults:
autoconfigure: true
plugin.manager.workflows.type:
class: Drupal\workflows\WorkflowTypeManager
parent: default_plugin_manager
tags:
- { name: plugin_manager_cache_clear }
workflows.access_check.extended_permissions:
class: Drupal\workflows\WorkflowStateTransitionOperationsAccessCheck
tags:
- { name: access_check, applies_to: _workflow_access }