diff --git a/core/core.services.yml b/core/core.services.yml index 5b0de75..c9db07b 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1657,3 +1657,8 @@ services: class: Drupal\Core\EventSubscriber\RssResponseRelativeUrlFilter tags: - { name: event_subscriber } + core.workflow_type_manager: + class: Drupal\Core\Workflow\WorkflowTypeManager + parent: default_plugin_manager + tags: + - { name: plugin_manager_cache_clear } diff --git a/core/lib/Drupal/Core/Workflow/Annotation/WorkflowType.php b/core/lib/Drupal/Core/Workflow/Annotation/WorkflowType.php new file mode 100644 index 0000000..8d5c046 --- /dev/null +++ b/core/lib/Drupal/Core/Workflow/Annotation/WorkflowType.php @@ -0,0 +1,44 @@ +states[$state_id])) { + throw new \InvalidArgumentException("The state '$state_id' already exists in workflow '{$this->id()}'"); + } + if (preg_match('/[^a-z0-9_]+/', $state_id)) { + throw new \InvalidArgumentException("The state ID '$state_id' must contain only lowercase letters, numbers, and underscores"); + } + $this->states[$state_id] = [ + 'label' => $label, + 'weight' => $this->getNextWeight($this->states), + ]; + return $this; + } + + /** + * {@inheritdoc} + */ + public function hasState($state_id) { + return isset($this->states[$state_id]); + } + + /** + * {@inheritdoc} + */ + public function getStates($state_ids = NULL) { + if ($state_ids === NULL) { + $state_ids = array_keys($this->states); + } + /** @var \Drupal\Core\Workflow\StateInterface[] $states */ + $states = array_combine($state_ids, array_map([$this, 'getState'], $state_ids)); + if (count($states) > 1) { + // Sort states by weight and then label. + $weights = $labels = []; + foreach ($states as $id => $state) { + $weights[$id] = $state->weight(); + $labels[$id] = $state->label(); + } + array_multisort( + $weights, SORT_NUMERIC, SORT_ASC, + $labels, SORT_NATURAL, SORT_ASC + ); + $states = array_replace($weights, $states); + } + return $states; + } + + /** + * {@inheritdoc} + */ + public function getState($state_id) { + if (!isset($this->states[$state_id])) { + throw new \InvalidArgumentException("The state '$state_id' does not exist in workflow '{$this->id()}'"); + } + $state = new State( + $this, + $state_id, + $this->states[$state_id]['label'], + $this->states[$state_id]['weight'] + ); + return $this->getTypePlugin()->decorateState($state); + } + + /** + * {@inheritdoc} + */ + public function setStateLabel($state_id, $label) { + if (!isset($this->states[$state_id])) { + throw new \InvalidArgumentException("The state '$state_id' does not exist in workflow '{$this->id()}'"); + } + $this->states[$state_id]['label'] = $label; + return $this; + } + + /** + * {@inheritdoc} + */ + public function setStateWeight($state_id, $weight) { + if (!isset($this->states[$state_id])) { + throw new \InvalidArgumentException("The state '$state_id' does not exist in workflow '{$this->id()}'"); + } + $this->states[$state_id]['weight'] = $weight; + return $this; + } + + /** + * {@inheritdoc} + */ + public function deleteState($state_id) { + if (!isset($this->states[$state_id])) { + throw new \InvalidArgumentException("The state '$state_id' does not exist in workflow '{$this->id()}'"); + } + foreach ($this->transitions as $transition_id => $transition) { + $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']) || $transition['to'] === $state_id) { + $this->deleteTransition($transition_id); + } + elseif ($from_key !== FALSE) { + $this->setTransitionFromStates($transition_id, $transition['from']); + } + } + unset($this->states[$state_id]); + $this->getTypePlugin()->deleteState($state_id); + return $this; + } + + /** + * {@inheritdoc} + */ + public function getInitialState() { + $ordered_states = $this->getStates(); + return reset($ordered_states); + } + + /** + * {@inheritdoc} + */ + public function addTransition($transition_id, $label, array $from_state_ids, $to_state_id) { + if (isset($this->transitions[$transition_id])) { + throw new \InvalidArgumentException("The transition '$transition_id' already exists in workflow '{$this->id()}'"); + } + if (preg_match('/[^a-z0-9_]+/', $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->id()}'"); + } + $this->transitions[$transition_id] = [ + 'label' => $label, + 'from' => [], + 'to' => $to_state_id, + // Always add to the end. + 'weight' => $this->getNextWeight($this->transitions), + ]; + + try { + $this->setTransitionFromStates($transition_id, $from_state_ids); + } + catch (\InvalidArgumentException $e) { + unset($this->transitions[$transition_id]); + throw $e; + } + + return $this; + } + + /** + * {@inheritdoc} + */ + public function getTransitions(array $transition_ids = NULL) { + if ($transition_ids === NULL) { + $transition_ids = array_keys($this->transitions); + } + /** @var \Drupal\Core\Workflow\TransitionInterface[] $transitions */ + $transitions = array_combine($transition_ids, array_map([$this, 'getTransition'], $transition_ids)); + if (count($transitions) > 1) { + // Sort transitions by weights and then labels. + $weights = $labels = []; + foreach ($transitions as $id => $transition) { + $weights[$id] = $transition->weight(); + $labels[$id] = $transition->label(); + } + array_multisort( + $weights, SORT_NUMERIC, SORT_ASC, + $labels, SORT_NATURAL, SORT_ASC + ); + $transitions = array_replace($weights, $transitions); + } + return $transitions; + } + + /** + * {@inheritdoc} + */ + public function getTransition($transition_id) { + if (!isset($this->transitions[$transition_id])) { + throw new \InvalidArgumentException("The transition '$transition_id' does not exist in workflow '{$this->id()}'"); + } + $transition = new Transition( + $this, + $transition_id, + $this->transitions[$transition_id]['label'], + $this->transitions[$transition_id]['from'], + $this->transitions[$transition_id]['to'], + $this->transitions[$transition_id]['weight'] + ); + return $this->getTypePlugin()->decorateTransition($transition); + } + + /** + * {@inheritdoc} + */ + public function hasTransition($transition_id) { + return isset($this->transitions[$transition_id]); + } + + /** + * {@inheritdoc} + */ + public function getTransitionsForState($state_id, $direction = 'from') { + if ($direction !== 'to' && $direction !== 'from') { + throw new \InvalidArgumentException("The direction '$direction' is invalid, the only valid values are 'to' and 'from'"); + } + $transition_ids = array_keys(array_filter($this->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 '{$this->id()}'"); + } + return $this->getTransition($transition_id); + } + + /** + * {@inheritdoc} + */ + public function hasTransitionFromStateToState($from_state_id, $to_state_id) { + return !empty($this->getTransitionIdFromStateToState($from_state_id, $to_state_id)); + } + + /** + * 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->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 (isset($this->transitions[$transition_id])) { + $this->transitions[$transition_id]['label'] = $label; + } + else { + throw new \InvalidArgumentException("The transition '$transition_id' does not exist in workflow '{$this->id()}'"); + } + return $this; + } + + /** + * {@inheritdoc} + */ + public function setTransitionWeight($transition_id, $weight) { + if (isset($this->transitions[$transition_id])) { + $this->transitions[$transition_id]['weight'] = $weight; + } + else { + throw new \InvalidArgumentException("The transition '$transition_id' does not exist in workflow '{$this->id()}'"); + } + return $this; + } + + /** + * {@inheritdoc} + */ + public function setTransitionFromStates($transition_id, array $from_state_ids) { + if (!isset($this->transitions[$transition_id])) { + throw new \InvalidArgumentException("The transition '$transition_id' does not exist in workflow '{$this->id()}'"); + } + + // 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 '{$this->id()}'"); + } + if ($this->hasTransitionFromStateToState($from_state_id, $this->transitions[$transition_id]['to'])) { + $transition = $this->getTransitionFromStateToState($from_state_id, $this->transitions[$transition_id]['to']); + if ($transition_id !== $transition->id()) { + throw new \InvalidArgumentException("The '{$transition->id()}' transition already allows '$from_state_id' to '{$this->transitions[$transition_id]['to']}' transitions in workflow '{$this->id()}'"); + } + } + } + + // 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->transitions[$transition_id]['from'] = $from_state_ids; + + return $this; + } + + /** + * {@inheritdoc} + */ + public function deleteTransition($transition_id) { + if (isset($this->transitions[$transition_id])) { + unset($this->transitions[$transition_id]); + $this->getTypePlugin()->deleteTransition($transition_id); + } + else { + throw new \InvalidArgumentException("The transition '$transition_id' does not exist in workflow '{$this->id()}'"); + } + return $this; + } + + /** + * {@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('core.workflow_type_manager'), $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\Core\Workflow\Annotation\WorkflowType + */ + public static function loadMultipleByType($type) { + return self::loadMultiple(\Drupal::entityQuery('workflow')->condition('type', $type)->execute()); + } + + /** + * 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); + } + +} diff --git a/core/lib/Drupal/Core/Workflow/Plugin/WorkflowTypeBase.php b/core/lib/Drupal/Core/Workflow/Plugin/WorkflowTypeBase.php new file mode 100644 index 0000000..3a094b3 --- /dev/null +++ b/core/lib/Drupal/Core/Workflow/Plugin/WorkflowTypeBase.php @@ -0,0 +1,119 @@ +getPluginDefinition(); + // The label can be an object. + // @see \Drupal\Core\StringTranslation\TranslatableMarkup + return $definition['label']; + } + + /** + * {@inheritdoc} + */ + public function checkWorkflowAccess(WorkflowInterface $entity, $operation, AccountInterface $account) { + return AccessResult::neutral(); + } + + /** + * {@inheritDoc} + */ + public function decorateState(StateInterface $state) { + return $state; + } + + /** + * {@inheritDoc} + */ + public function deleteState($state_id) { + unset($this->configuration['states'][$state_id]); + } + + /** + * {@inheritDoc} + */ + public function decorateTransition(TransitionInterface $transition) { + return $transition; + } + + /** + * {@inheritDoc} + */ + public function deleteTransition($transition_id) { + unset($this->configuration['transitions'][$transition_id]); + } + + /** + * {@inheritdoc} + */ + public function buildStateConfigurationForm(FormStateInterface $form_state, WorkflowInterface $workflow, StateInterface $state = NULL) { + return []; + } + + /** + * {@inheritdoc} + */ + public function buildTransitionConfigurationForm(FormStateInterface $form_state, WorkflowInterface $workflow, TransitionInterface $transition = NULL) { + return []; + } + + /** + * {@inheritDoc} + */ + public function getConfiguration() { + return $this->configuration; + } + + /** + * {@inheritDoc} + */ + public function setConfiguration(array $configuration) { + $this->configuration = NestedArray::mergeDeep( + $this->defaultConfiguration(), + $configuration + ); + } + + /** + * {@inheritDoc} + */ + public function defaultConfiguration() { + return [ + 'states' => [], + 'transitions' => [], + ]; + } + + /** + * {@inheritDoc} + */ + public function calculateDependencies() { + return []; + } + +} diff --git a/core/lib/Drupal/Core/Workflow/State.php b/core/lib/Drupal/Core/Workflow/State.php new file mode 100644 index 0000000..d8dea01 --- /dev/null +++ b/core/lib/Drupal/Core/Workflow/State.php @@ -0,0 +1,118 @@ +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 list of states to labels + * + * @param \Drupal\Core\Workflow\StateInterface $state + * + * @return string + * The label of the state. + */ + public static function labelCallback(StateInterface $state) { + return $state->label(); + } + +} diff --git a/core/lib/Drupal/Core/Workflow/StateInterface.php b/core/lib/Drupal/Core/Workflow/StateInterface.php new file mode 100644 index 0000000..524f94b --- /dev/null +++ b/core/lib/Drupal/Core/Workflow/StateInterface.php @@ -0,0 +1,73 @@ +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; + } + +} diff --git a/core/lib/Drupal/Core/Workflow/TransitionInterface.php b/core/lib/Drupal/Core/Workflow/TransitionInterface.php new file mode 100644 index 0000000..a819310 --- /dev/null +++ b/core/lib/Drupal/Core/Workflow/TransitionInterface.php @@ -0,0 +1,54 @@ +get('core.workflow_type_manager') + ); + } + + /** + * 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) { + $admin_access = parent::checkAccess($entity, $operation, $account); + /** @var \Drupal\Core\Workflow\WorkflowInterface $entity */ + return $entity->getTypePlugin()->checkWorkflowAccess($entity, $operation, $account)->orIf($admin_access); + } + + /** + * {@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))->addCacheTags(['config:core.extension']); + } + +} diff --git a/core/lib/Drupal/Core/Workflow/WorkflowInterface.php b/core/lib/Drupal/Core/Workflow/WorkflowInterface.php new file mode 100644 index 0000000..853379e --- /dev/null +++ b/core/lib/Drupal/Core/Workflow/WorkflowInterface.php @@ -0,0 +1,289 @@ +alterInfo('workflow_type_info'); + $this->setCacheBackend($cache_backend, 'workflow_type_info'); + } + +}