diff --git a/core/composer.json b/core/composer.json index 7ae0ccf..082b7c0 100644 --- a/core/composer.json +++ b/core/composer.json @@ -141,7 +141,8 @@ "drupal/update": "self.version", "drupal/user": "self.version", "drupal/views": "self.version", - "drupal/views_ui": "self.version" + "drupal/views_ui": "self.version", + "drupal/workflow_ui": "self.version" }, "minimum-stability": "dev", "prefer-stable": true, diff --git a/core/config/schema/core.workflow.schema.yml b/core/config/schema/core.workflow.schema.yml new file mode 100644 index 0000000..7e2ae45 --- /dev/null +++ b/core/config/schema/core.workflow.schema.yml @@ -0,0 +1,45 @@ +core.workflow.*: + type: config_entity + label: 'Workflow' + mapping: + id: + type: string + label: 'ID' + label: + type: label + label: 'Label' + type: + type: string + label: 'Workflow type' + type_settings: + type: workflow.type_settings.[%parent.type] + label: 'Custom settings for workflow type' + states: + type: sequence + label: 'States' + sequence: + type: mapping + label: 'State' + mapping: + label: + type: label + label: 'Label' + weight: + type: integer + label: 'Weight' + transitions: + type: sequence + label: 'Transitions' + sequence: + type: mapping + label: 'Transition from state to state' + mapping: + from: + type: string + label: 'From state ID' + to: + type: string + label: 'To state ID' + label: + type: label + label: 'Transition label' diff --git a/core/core.services.yml b/core/core.services.yml index 5c42b23..9cdf491 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1652,3 +1652,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"); + } + // Always add to the end. + $weight = array_reduce($this->states, function ($carry, $state_info) { + return $carry = max($carry, $state_info['weight'] + 1); + }, 0); + $this->states[$state_id] = [ + 'label' => $label, + 'weight' => $weight, + ]; + 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()}'"); + } + unset($this->states[$state_id]); + foreach ($this->transitions as $transition_id => $transition) { + if ($transition['from'] === $state_id || $transition['to'] === $state_id) { + $this->deleteTransition($transition_id); + } + } + $this->getTypePlugin()->deleteState($state_id); + return $this; + } + + /** + * {@inheritdoc} + */ + public function getInitialState() { + $ordered_states = $this->getStates(); + return reset($ordered_states); + } + + /** + * Get's the ID for a transition. + * + * @param string $from_state_id + * The state ID to transition from. + * @param string $to_state_id + * The state ID to transition to. + * + * @return string + * The transition's ID. + */ + protected function getTransitionId($from_state_id, $to_state_id) { + // State IDs are guaranteed to not have the hyphen character therefore it is + // safe to use. + return $from_state_id . '-' . $to_state_id; + } + + /** + * {@inheritdoc} + */ + public function addTransition($from_state_id, $to_state_id, $label) { + if (!$this->hasTransitionFromStateToState($from_state_id, $to_state_id)) { + // Ensure that the states exist. + if (!$this->hasState($from_state_id)) { + throw new \InvalidArgumentException("The state '$from_state_id' does not exist in workflow '{$this->id()}'"); + } + if (!$this->hasState($to_state_id)) { + throw new \InvalidArgumentException("The state '$to_state_id' does not exist in workflow '{$this->id()}'"); + } + $this->transitions[$this->getTransitionId($from_state_id, $to_state_id)] = [ + 'from' => $from_state_id, + 'to' => $to_state_id, + 'label' => $label, + ]; + } + 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 from state weights and then to state weights and + // then transition labels. + $from_weights = $to_weights = $labels = []; + foreach ($transitions as $id => $transition) { + $from_weights[$id] = $transition->from()->weight(); + $to_weights[$id] = $transition->to()->weight(); + $labels[$id] = $transition->label(); + } + array_multisort( + $from_weights, SORT_NUMERIC, SORT_ASC, + $to_weights, SORT_NUMERIC, SORT_ASC, + $labels, SORT_NATURAL, SORT_ASC + ); + $transitions = array_replace($from_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]['from'], + $this->transitions[$transition_id]['to'], + $this->transitions[$transition_id]['label'] + ); + return $this->getTypePlugin()->decorateTransition($transition); + } + + /** + * {@inheritdoc} + */ + public function getTransitionsForState($state_id, $direction = 'from') { + $transition_ids = array_keys(array_filter($this->transitions, function ($transition) use ($state_id, $direction) { + return $transition[$direction] === $state_id; + })); + return $this->getTransitions($transition_ids); + } + + /** + * {@inheritdoc} + */ + public function getTransitionFromStateToState($from_state_id, $to_state_id) { + return $this->getTransition($this->getTransitionId($from_state_id, $to_state_id)); + } + + /** + * {@inheritdoc} + */ + public function hasTransitionFromStateToState($from_state_id, $to_state_id) { + return isset($this->transitions[$this->getTransitionId($from_state_id, $to_state_id)]); + } + + /** + * {@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 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()); + } + +} 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..386e52e --- /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..8a505be --- /dev/null +++ b/core/lib/Drupal/Core/Workflow/StateInterface.php @@ -0,0 +1,72 @@ +workflow = $workflow; + $this->id = $id; + $this->fromStateId = $from_state_id; + $this->toStateId = $to_state_id; + $this->label = $label; + } + + /** + * {@inheritdoc} + */ + public function id() { + return $this->id; + } + + /** + * {@inheritdoc} + */ + public function label() { + return $this->label; + } + + /** + * {@inheritdoc} + */ + public function from() { + return $this->workflow->getState($this->fromStateId); + } + + /** + * {@inheritdoc} + */ + public function to() { + return $this->workflow->getState($this->toStateId); + } + +} diff --git a/core/lib/Drupal/Core/Workflow/TransitionInterface.php b/core/lib/Drupal/Core/Workflow/TransitionInterface.php new file mode 100644 index 0000000..0d11a8c --- /dev/null +++ b/core/lib/Drupal/Core/Workflow/TransitionInterface.php @@ -0,0 +1,46 @@ +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(\Drupal::service('core.workflow_type_manager')->getDefinitions()); + $admin_access = parent::checkCreateAccess($account, $context, $entity_bundle); + // 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..4664b2c --- /dev/null +++ b/core/lib/Drupal/Core/Workflow/WorkflowInterface.php @@ -0,0 +1,244 @@ +alterInfo('workflow_type_info'); + $this->setCacheBackend($cache_backend, 'workflow_type_info'); + } + +} diff --git a/core/modules/content_moderation/config/install/content_moderation.state.archived.yml b/core/modules/content_moderation/config/install/content_moderation.state.archived.yml deleted file mode 100644 index 0279481..0000000 --- a/core/modules/content_moderation/config/install/content_moderation.state.archived.yml +++ /dev/null @@ -1,8 +0,0 @@ -langcode: en -status: true -dependencies: { } -id: archived -label: Archived -published: false -default_revision: true -weight: -8 diff --git a/core/modules/content_moderation/config/install/content_moderation.state.draft.yml b/core/modules/content_moderation/config/install/content_moderation.state.draft.yml deleted file mode 100644 index c7eb64c..0000000 --- a/core/modules/content_moderation/config/install/content_moderation.state.draft.yml +++ /dev/null @@ -1,8 +0,0 @@ -langcode: en -status: true -dependencies: { } -id: draft -label: Draft -published: false -default_revision: false -weight: -10 diff --git a/core/modules/content_moderation/config/install/content_moderation.state.published.yml b/core/modules/content_moderation/config/install/content_moderation.state.published.yml deleted file mode 100644 index 8467e86..0000000 --- a/core/modules/content_moderation/config/install/content_moderation.state.published.yml +++ /dev/null @@ -1,8 +0,0 @@ -langcode: en -status: true -dependencies: { } -id: published -label: Published -published: true -default_revision: true -weight: -9 diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_draft.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_draft.yml deleted file mode 100644 index 8fbf9c3..0000000 --- a/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_draft.yml +++ /dev/null @@ -1,11 +0,0 @@ -langcode: en -status: true -dependencies: - config: - - content_moderation.state.archived - - content_moderation.state.draft -id: archived_draft -label: 'Un-archive to Draft' -stateFrom: archived -stateTo: draft -weight: -5 diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_published.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_published.yml deleted file mode 100644 index 4be7600..0000000 --- a/core/modules/content_moderation/config/install/content_moderation.state_transition.archived_published.yml +++ /dev/null @@ -1,11 +0,0 @@ -langcode: en -status: true -dependencies: - config: - - content_moderation.state.archived - - content_moderation.state.published -id: archived_published -label: 'Un-archive' -stateFrom: archived -stateTo: published -weight: -4 diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_draft.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_draft.yml deleted file mode 100644 index 0ba0f34..0000000 --- a/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_draft.yml +++ /dev/null @@ -1,10 +0,0 @@ -langcode: en -status: true -dependencies: - config: - - content_moderation.state.draft -id: draft_draft -label: 'Create New Draft' -stateFrom: draft -stateTo: draft -weight: -10 diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_published.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_published.yml deleted file mode 100644 index cf95d3d..0000000 --- a/core/modules/content_moderation/config/install/content_moderation.state_transition.draft_published.yml +++ /dev/null @@ -1,11 +0,0 @@ -langcode: en -status: true -dependencies: - config: - - content_moderation.state.draft - - content_moderation.state.published -id: draft_published -label: 'Publish' -stateFrom: draft -stateTo: published -weight: -9 diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.published_archived.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.published_archived.yml deleted file mode 100644 index f3a866a..0000000 --- a/core/modules/content_moderation/config/install/content_moderation.state_transition.published_archived.yml +++ /dev/null @@ -1,11 +0,0 @@ -langcode: en -status: true -dependencies: - config: - - content_moderation.state.archived - - content_moderation.state.published -id: published_archived -label: 'Archive' -stateFrom: published -stateTo: archived -weight: -6 diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.published_draft.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.published_draft.yml deleted file mode 100644 index bd25a31..0000000 --- a/core/modules/content_moderation/config/install/content_moderation.state_transition.published_draft.yml +++ /dev/null @@ -1,11 +0,0 @@ -langcode: en -status: true -dependencies: - config: - - content_moderation.state.draft - - content_moderation.state.published -id: published_draft -label: 'Create New Draft' -stateFrom: published -stateTo: draft -weight: -8 diff --git a/core/modules/content_moderation/config/install/content_moderation.state_transition.published_published.yml b/core/modules/content_moderation/config/install/content_moderation.state_transition.published_published.yml deleted file mode 100644 index 3c09a85..0000000 --- a/core/modules/content_moderation/config/install/content_moderation.state_transition.published_published.yml +++ /dev/null @@ -1,10 +0,0 @@ -langcode: en -status: true -dependencies: - config: - - content_moderation.state.published -id: published_published -label: 'Publish' -stateFrom: published -stateTo: published -weight: -7 diff --git a/core/modules/content_moderation/config/install/core.workflow.typical.yml b/core/modules/content_moderation/config/install/core.workflow.typical.yml new file mode 100644 index 0000000..28749ec --- /dev/null +++ b/core/modules/content_moderation/config/install/core.workflow.typical.yml @@ -0,0 +1,59 @@ +langcode: en +status: true +dependencies: + module: + - content_moderation +id: typical +label: 'Typical workflow' +states: + archived: + label: Archived + weight: 5 + draft: + label: Draft + weight: -5 + published: + label: Published + weight: 0 +transitions: + archived-draft: + from: archived + to: draft + label: 'Un-archive to Draft' + archived-published: + from: archived + to: published + label: Un-archive + draft-draft: + from: draft + to: draft + label: 'Create New Draft' + draft-published: + from: draft + to: published + label: Publish + published-archived: + from: published + to: archived + label: Archive + published-draft: + from: published + to: draft + label: 'Create New Draft' + published-published: + from: published + to: published + label: Publish +type: content_moderation +type_settings: + states: + archived: + published: false + default_revision: true + draft: + published: false + default_revision: false + published: + published: true + default_revision: true + entity_types: { } diff --git a/core/modules/content_moderation/config/schema/content_moderation.schema.yml b/core/modules/content_moderation/config/schema/content_moderation.schema.yml index 7f9e8fd..b791b67 100644 --- a/core/modules/content_moderation/config/schema/content_moderation.schema.yml +++ b/core/modules/content_moderation/config/schema/content_moderation.schema.yml @@ -1,79 +1,33 @@ -content_moderation.state.*: - type: config_entity - label: 'Moderation state config' - mapping: - id: - type: string - label: 'ID' - label: - type: label - label: 'Label' - published: - type: boolean - label: 'Is published' - default_revision: - type: boolean - label: 'Is default revision' - weight: - type: integer - label: 'Weight' - -content_moderation.state_transition.*: - type: config_entity - label: 'Moderation state transition config' +views.filter.latest_revision: + type: views_filter + label: 'Latest revision' mapping: - id: - type: string - label: 'ID' - label: - type: label - label: 'Label' - stateFrom: - type: string - label: 'From state' - stateTo: + value: type: string - label: 'To state' - weight: - type: integer - label: 'Weight' + label: 'Value' -node.type.*.third_party.content_moderation: +workflow.type_settings.content_moderation: type: mapping - label: 'Enable moderation states for this node type' mapping: - enabled: - type: boolean - label: 'Moderation states enabled' - allowed_moderation_states: + states: type: sequence + label: 'Additional state configuration for content moderation' sequence: - type: string - label: 'Moderation state' - default_moderation_state: - type: string - label: 'Moderation state for new content' - -block_content.type.*.third_party.content_moderation: - type: mapping - label: 'Enable moderation states for this block content type' - mapping: - enabled: - type: boolean - label: 'Moderation states enabled' - allowed_moderation_states: + type: mapping + label: 'States' + mapping: + published: + type: boolean + label: 'Is published' + default_revision: + type: boolean + label: 'Is default revision' + entity_types: type: sequence + label: 'Entity types' sequence: - type: string - label: 'Moderation state' - default_moderation_state: - type: string - label: 'Moderation state for new block content' - -views.filter.latest_revision: - type: views_filter - label: 'Latest revision' - mapping: - value: - type: string - label: 'Value' + type: sequence + label: 'Bundles' + sequence: + type: string + label: 'Bundle ID' diff --git a/core/modules/content_moderation/content_moderation.info.yml b/core/modules/content_moderation/content_moderation.info.yml index 6d92b64..9d58bbd 100644 --- a/core/modules/content_moderation/content_moderation.info.yml +++ b/core/modules/content_moderation/content_moderation.info.yml @@ -5,3 +5,5 @@ version: VERSION core: 8.x package: Core (Experimental) configure: content_moderation.overview +dependencies: + - workflow_ui diff --git a/core/modules/content_moderation/content_moderation.links.action.yml b/core/modules/content_moderation/content_moderation.links.action.yml deleted file mode 100644 index cbb2d3f..0000000 --- a/core/modules/content_moderation/content_moderation.links.action.yml +++ /dev/null @@ -1,11 +0,0 @@ -entity.moderation_state.add_form: - route_name: 'entity.moderation_state.add_form' - title: 'Add moderation state' - appears_on: - - entity.moderation_state.collection - -entity.moderation_state_transition.add_form: - route_name: 'entity.moderation_state_transition.add_form' - title: 'Add moderation state transition' - appears_on: - - entity.moderation_state_transition.collection diff --git a/core/modules/content_moderation/content_moderation.links.menu.yml b/core/modules/content_moderation/content_moderation.links.menu.yml deleted file mode 100644 index 0fcb3eb..0000000 --- a/core/modules/content_moderation/content_moderation.links.menu.yml +++ /dev/null @@ -1,21 +0,0 @@ -# Moderation state menu items definition -content_moderation.overview: - title: 'Content moderation' - route_name: content_moderation.overview - description: 'Configure states and transitions for entities.' - parent: system.admin_config_workflow - -entity.moderation_state.collection: - title: 'Moderation states' - route_name: entity.moderation_state.collection - description: 'Administer moderation states.' - parent: content_moderation.overview - weight: 10 - -# Moderation state transition menu items definition -entity.moderation_state_transition.collection: - title: 'Moderation state transitions' - route_name: entity.moderation_state_transition.collection - description: 'Administer moderation states transitions.' - parent: content_moderation.overview - weight: 20 diff --git a/core/modules/content_moderation/content_moderation.links.task.yml b/core/modules/content_moderation/content_moderation.links.task.yml index d715219..f92e92e 100644 --- a/core/modules/content_moderation/content_moderation.links.task.yml +++ b/core/modules/content_moderation/content_moderation.links.task.yml @@ -1,3 +1,3 @@ -moderation_state.entities: +content_moderation.workflows: deriver: 'Drupal\content_moderation\Plugin\Derivative\DynamicLocalTasks' weight: 100 diff --git a/core/modules/content_moderation/content_moderation.module b/core/modules/content_moderation/content_moderation.module index 582242b..8ab3819 100644 --- a/core/modules/content_moderation/content_moderation.module +++ b/core/modules/content_moderation/content_moderation.module @@ -21,6 +21,7 @@ use Drupal\node\NodeInterface; use Drupal\node\Plugin\Action\PublishNode; use Drupal\node\Plugin\Action\UnpublishNode; +use Drupal\Core\Workflow\Entity\Workflow; /** * Implements hook_help(). @@ -31,13 +32,9 @@ function content_moderation_help($route_name, RouteMatchInterface $route_match) case 'help.page.content_moderation': $output = ''; $output .= '

' . t('About') . '

'; - $output .= '

' . t('The Content Moderation module provides basic moderation for content. This lets site admins define states for content, and then define transitions between those states. For more information, see the online documentation for the Content Moderation module.', [':content_moderation' => 'https://www.drupal.org/documentation/modules/content_moderation']) . '

'; + $output .= '

' . t('The Content Moderation module provides moderation for content by applying workflows to content. For more information, see the online documentation for the Content Moderation module.', [':content_moderation' => 'https://www.drupal.org/documentation/modules/content_moderation']) . '

'; $output .= '

' . t('Uses') . '

'; $output .= '
'; - $output .= '
' . t('Moderation states') . '
'; - $output .= '
' . t('Moderation states provide the Draft and Archived states as additions to the basic Published option. You can click the blue Add Moderation state button and create new states.') . '
'; - $output .= '
' . t('Moderation state transitions') . '
'; - $output .= '
' . t('Using the "Moderation state transitions" screen, you can create the actual workflow. You decide the direction in which content moves from state to state, and which user roles are allowed to make that move.') . '
'; $output .= '
' . t('Configure Content Moderation permissions') . '
'; $output .= '
' . t('Each state is exposed as a permission. If a user has the permission for a transition, then they can move that node from the start state to the end state') . '

'; $output .= '
'; @@ -182,15 +179,17 @@ function content_moderation_node_access(NodeInterface $node, $operation, Account $access_result->addCacheableDependency($node); } - elseif ($operation === 'update' && $moderation_info->isModeratedEntity($node) && $node->moderation_state && $node->moderation_state->target_id) { + elseif ($operation === 'update' && $moderation_info->isModeratedEntity($node) && $node->moderation_state) { /** @var \Drupal\content_moderation\StateTransitionValidation $transition_validation */ $transition_validation = \Drupal::service('content_moderation.state_transition_validation'); - $valid_transition_targets = $transition_validation->getValidTransitionTargets($node, $account); + $valid_transition_targets = $transition_validation->getValidTransitions($node, $account); $access_result = $valid_transition_targets ? AccessResult::neutral() : AccessResult::forbidden(); $access_result->addCacheableDependency($node); $access_result->addCacheableDependency($account); + $workflow = \Drupal::service('content_moderation.moderation_information')->getWorkflowForEntity($node); + $access_result->addCacheableDependency($workflow); foreach ($valid_transition_targets as $valid_transition_target) { $access_result->addCacheableDependency($valid_transition_target); } @@ -222,3 +221,21 @@ function content_moderation_action_info_alter(&$definitions) { $definitions['node_unpublish_action']['class'] = ModerationOptOutUnpublishNode::class; } } + +/** + * Implements hook_entity_bundle_info_alter(). + */ +function content_moderation_entity_bundle_info_alter(&$bundles) { + /** @var \Drupal\Core\Workflow\WorkflowInterface $workflow */ + foreach (Workflow::loadMultipleByType('content_moderation') as $workflow) { + /** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModeration $plugin */ + $plugin = $workflow->getTypePlugin(); + foreach ($plugin->getEntityTypes() as $entity_type_id) { + foreach ($plugin->getBundlesForEntityType($entity_type_id) as $bundle_id) { + if (isset($bundles[$entity_type_id][$bundle_id])) { + $bundles[$entity_type_id][$bundle_id]['workflow'] = $workflow->id(); + } + } + } + } +} diff --git a/core/modules/content_moderation/content_moderation.permissions.yml b/core/modules/content_moderation/content_moderation.permissions.yml index 293a77d..af28bbf 100644 --- a/core/modules/content_moderation/content_moderation.permissions.yml +++ b/core/modules/content_moderation/content_moderation.permissions.yml @@ -2,18 +2,13 @@ view any unpublished content: title: 'View any unpublished content' description: 'This permission is necessary for any users that may moderate content.' -'view moderation states': - title: 'View moderation states' - description: 'View moderation states.' +'view content moderation': + title: 'View content moderation' + description: 'View content moderation.' -'administer moderation states': - title: 'Administer moderation states' - description: 'Create and edit moderation states.' - 'restrict access': TRUE - -'administer moderation state transitions': - title: 'Administer content moderation state transitions' - description: 'Create and edit content moderation state transitions.' +'administer content moderation': + title: 'Administer content moderation' + description: 'Administer workflows on content entities.' 'restrict access': TRUE view latest version: diff --git a/core/modules/content_moderation/content_moderation.routing.yml b/core/modules/content_moderation/content_moderation.routing.yml deleted file mode 100644 index 912eed8..0000000 --- a/core/modules/content_moderation/content_moderation.routing.yml +++ /dev/null @@ -1,7 +0,0 @@ -content_moderation.overview: - path: '/admin/config/workflow/moderation' - defaults: - _controller: '\Drupal\system\Controller\SystemController::systemAdminMenuBlockPage' - _title: 'Content moderation' - requirements: - _permission: 'access administration pages' diff --git a/core/modules/content_moderation/content_moderation.services.yml b/core/modules/content_moderation/content_moderation.services.yml index 75f0c64..904bc0d 100644 --- a/core/modules/content_moderation/content_moderation.services.yml +++ b/core/modules/content_moderation/content_moderation.services.yml @@ -6,10 +6,10 @@ services: - { name: paramconverter, priority: 5 } content_moderation.state_transition_validation: class: \Drupal\content_moderation\StateTransitionValidation - arguments: ['@entity_type.manager', '@entity.query'] + arguments: ['@content_moderation.moderation_information'] content_moderation.moderation_information: class: Drupal\content_moderation\ModerationInformation - arguments: ['@entity_type.manager'] + arguments: ['@entity_type.manager', '@entity_type.bundle.info'] access_check.latest_revision: class: Drupal\content_moderation\Access\LatestRevisionCheck arguments: ['@content_moderation.moderation_information'] diff --git a/core/modules/content_moderation/src/ContentModerationState.php b/core/modules/content_moderation/src/ContentModerationState.php new file mode 100644 index 0000000..265a01c --- /dev/null +++ b/core/modules/content_moderation/src/ContentModerationState.php @@ -0,0 +1,114 @@ +state = $state; + $this->published = $published; + $this->defaultRevision = $default_revision; + } + + /** + * Determines if entities should be published if in this state. + * + * @return bool + */ + public function isPublishedState() { + return $this->published; + } + + /** + * Determines if entities should be the default revision if in this state. + * + * @return bool + */ + public function isDefaultRevisionState() { + return $this->defaultRevision; + } + + /** + * {@inheritdoc} + */ + public function id() { + return $this->state->id(); + } + + /** + * {@inheritdoc} + */ + public function label() { + return $this->state->label(); + } + + /** + * {@inheritdoc} + */ + public function weight() { + return $this->state->weight(); + } + + /** + * {@inheritdoc} + */ + public function canTransitionTo($to_state_id) { + return $this->state->canTransitionTo($to_state_id); + } + + /** + * {@inheritdoc} + */ + public function getTransitionTo($to_state_id) { + return $this->state->getTransitionTo($to_state_id); + } + + /** + * {@inheritdoc} + */ + public function getTransitions() { + return $this->state->getTransitions(); + } + +} diff --git a/core/modules/content_moderation/src/Entity/ContentModerationState.php b/core/modules/content_moderation/src/Entity/ContentModerationState.php index 978408f..d60dad7 100644 --- a/core/modules/content_moderation/src/Entity/ContentModerationState.php +++ b/core/modules/content_moderation/src/Entity/ContentModerationState.php @@ -55,10 +55,17 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { ->setTranslatable(TRUE) ->setRevisionable(TRUE); - $fields['moderation_state'] = BaseFieldDefinition::create('entity_reference') + $fields['workflow'] = BaseFieldDefinition::create('entity_reference') + ->setLabel(t('Workflow')) + ->setDescription(t('The workflow the moderation state is in.')) + ->setSetting('target_type', 'workflow') + ->setRequired(TRUE) + ->setTranslatable(TRUE) + ->setRevisionable(TRUE); + + $fields['moderation_state'] = BaseFieldDefinition::create('string') ->setLabel(t('Moderation state')) ->setDescription(t('The moderation state of the referenced content.')) - ->setSetting('target_type', 'moderation_state') ->setRequired(TRUE) ->setTranslatable(TRUE) ->setRevisionable(TRUE) @@ -155,7 +162,7 @@ public function save() { if ($related_entity instanceof TranslatableInterface) { $related_entity = $related_entity->getTranslation($this->activeLangcode); } - $related_entity->moderation_state->target_id = $this->moderation_state->target_id; + $related_entity->moderation_state = $this->moderation_state; return $related_entity->save(); } diff --git a/core/modules/content_moderation/src/Entity/Handler/NodeModerationHandler.php b/core/modules/content_moderation/src/Entity/Handler/NodeModerationHandler.php index 83de187..247d352 100644 --- a/core/modules/content_moderation/src/Entity/Handler/NodeModerationHandler.php +++ b/core/modules/content_moderation/src/Entity/Handler/NodeModerationHandler.php @@ -2,8 +2,11 @@ namespace Drupal\content_moderation\Entity\Handler; +use Drupal\content_moderation\ModerationInformationInterface; use Drupal\Core\Entity\ContentEntityInterface; +use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Form\FormStateInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; /** * Customizations for node entities. @@ -11,6 +14,32 @@ class NodeModerationHandler extends ModerationHandler { /** + * The moderation information service. + * + * @var \Drupal\content_moderation\ModerationInformationInterface + */ + protected $moderationInfo; + + /** + * NodeModerationHandler constructor. + * + * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info + * The moderation information service. + */ + public function __construct(ModerationInformationInterface $moderation_info) { + $this->moderationInfo = $moderation_info; + } + + /** + * {@inheritdoc} + */ + public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type) { + return new static( + $container->get('content_moderation.moderation_information') + ); + } + + /** * {@inheritdoc} */ public function onPresave(ContentEntityInterface $entity, $default_revision, $published_state) { @@ -38,7 +67,7 @@ public function enforceRevisionsBundleFormAlter(array &$form, FormStateInterface /* @var \Drupal\node\Entity\NodeType $entity */ $entity = $form_state->getFormObject()->getEntity(); - if ($entity->getThirdPartySetting('content_moderation', 'enabled', FALSE)) { + if ($this->moderationInfo->getWorkFlowForEntity($entity)) { // Force the revision checkbox on. $form['workflow']['options']['#default_value']['revision'] = 'revision'; $form['workflow']['options']['revision']['#disabled'] = TRUE; diff --git a/core/modules/content_moderation/src/Entity/ModerationState.php b/core/modules/content_moderation/src/Entity/ModerationState.php deleted file mode 100644 index 379ff60..0000000 --- a/core/modules/content_moderation/src/Entity/ModerationState.php +++ /dev/null @@ -1,102 +0,0 @@ -published; - } - - /** - * {@inheritdoc} - */ - public function isDefaultRevisionState() { - return $this->published || $this->default_revision; - } - -} diff --git a/core/modules/content_moderation/src/Entity/ModerationStateTransition.php b/core/modules/content_moderation/src/Entity/ModerationStateTransition.php deleted file mode 100644 index 95a115b..0000000 --- a/core/modules/content_moderation/src/Entity/ModerationStateTransition.php +++ /dev/null @@ -1,114 +0,0 @@ -stateFrom) { - $this->addDependency('config', ModerationState::load($this->stateFrom)->getConfigDependencyName()); - } - if ($this->stateTo) { - $this->addDependency('config', ModerationState::load($this->stateTo)->getConfigDependencyName()); - } - return $this; - } - - /** - * {@inheritdoc} - */ - public function getFromState() { - return $this->stateFrom; - } - - /** - * {@inheritdoc} - */ - public function getToState() { - return $this->stateTo; - } - - /** - * {@inheritdoc} - */ - public function getWeight() { - return $this->weight; - } - -} diff --git a/core/modules/content_moderation/src/EntityOperations.php b/core/modules/content_moderation/src/EntityOperations.php index d76a35b..0fa1dd7 100644 --- a/core/modules/content_moderation/src/EntityOperations.php +++ b/core/modules/content_moderation/src/EntityOperations.php @@ -2,14 +2,16 @@ namespace Drupal\content_moderation; -use Drupal\content_moderation\Entity\ContentModerationState; +use Drupal\content_moderation\Entity\ContentModerationState as ContentModerationStateEntity; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\Entity\Display\EntityViewDisplayInterface; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityTypeBundleInfoInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Form\FormBuilderInterface; use Drupal\Core\TypedData\TranslatableInterface; use Drupal\content_moderation\Form\EntityModerationForm; +use Drupal\Core\Workflow\WorkflowInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -46,6 +48,13 @@ class EntityOperations implements ContainerInjectionInterface { protected $tracker; /** + * The entity bundle information service. + * + * @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface + */ + protected $bundleInfo; + + /** * Constructs a new EntityOperations object. * * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info @@ -56,12 +65,15 @@ class EntityOperations implements ContainerInjectionInterface { * The form builder. * @param \Drupal\content_moderation\RevisionTrackerInterface $tracker * The revision tracker. + * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $bundle_info + * The entity bundle information service. */ - public function __construct(ModerationInformationInterface $moderation_info, EntityTypeManagerInterface $entity_type_manager, FormBuilderInterface $form_builder, RevisionTrackerInterface $tracker) { + public function __construct(ModerationInformationInterface $moderation_info, EntityTypeManagerInterface $entity_type_manager, FormBuilderInterface $form_builder, RevisionTrackerInterface $tracker, EntityTypeBundleInfoInterface $bundle_info) { $this->moderationInfo = $moderation_info; $this->entityTypeManager = $entity_type_manager; $this->formBuilder = $form_builder; $this->tracker = $tracker; + $this->bundleInfo = $bundle_info; } /** @@ -72,7 +84,8 @@ public static function create(ContainerInterface $container) { $container->get('content_moderation.moderation_information'), $container->get('entity_type.manager'), $container->get('form_builder'), - $container->get('content_moderation.revision_tracker') + $container->get('content_moderation.revision_tracker'), + $container->get('entity_type.bundle.info') ); } @@ -86,20 +99,20 @@ public function entityPresave(EntityInterface $entity) { if (!$this->moderationInfo->isModeratedEntity($entity)) { return; } - if ($entity->moderation_state->target_id) { - $moderation_state = $this->entityTypeManager - ->getStorage('moderation_state') - ->load($entity->moderation_state->target_id); - $published_state = $moderation_state->isPublishedState(); + + if ($entity->moderation_state->value) { + $workflow = $this->moderationInfo->getWorkFlowForEntity($entity); + /** @var \Drupal\content_moderation\ContentModerationState $current_state */ + $current_state = $workflow->getState($entity->moderation_state->value); // This entity is default if it is new, the default revision, or the // default revision is not published. $update_default_revision = $entity->isNew() - || $moderation_state->isDefaultRevisionState() - || !$this->isDefaultRevisionPublished($entity); + || $current_state->isDefaultRevisionState() + || !$this->isDefaultRevisionPublished($entity, $workflow); // Fire per-entity-type logic for handling the save process. - $this->entityTypeManager->getHandler($entity->getEntityTypeId(), 'moderation')->onPresave($entity, $update_default_revision, $published_state); + $this->entityTypeManager->getHandler($entity->getEntityTypeId(), 'moderation')->onPresave($entity, $update_default_revision, $current_state->isPublishedState()); } } @@ -140,15 +153,14 @@ public function entityUpdate(EntityInterface $entity) { * The entity to update or create a moderation state for. */ protected function updateOrCreateFromEntity(EntityInterface $entity) { - $moderation_state = $entity->moderation_state->target_id; + $moderation_state = $entity->moderation_state->value; + $workflow = $this->moderationInfo->getWorkFlowForEntity($entity); /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ if (!$moderation_state) { - $moderation_state = $this->entityTypeManager - ->getStorage($entity->getEntityType()->getBundleEntityType())->load($entity->bundle()) - ->getThirdPartySetting('content_moderation', 'default_moderation_state'); + $moderation_state = $workflow->getInitialState()->id(); } - // @todo what if $entity->moderation_state->target_id is null at this point? + // @todo what if $entity->moderation_state is null at this point? $entity_type_id = $entity->getEntityTypeId(); $entity_id = $entity->id(); $entity_revision_id = $entity->getRevisionId(); @@ -157,6 +169,7 @@ protected function updateOrCreateFromEntity(EntityInterface $entity) { $entities = $storage->loadByProperties([ 'content_entity_type_id' => $entity_type_id, 'content_entity_id' => $entity_id, + 'workflow' => $workflow->id(), ]); /** @var \Drupal\content_moderation\ContentModerationStateInterface $content_moderation_state */ @@ -166,6 +179,7 @@ protected function updateOrCreateFromEntity(EntityInterface $entity) { 'content_entity_type_id' => $entity_type_id, 'content_entity_id' => $entity_id, ]); + $content_moderation_state->workflow->target_id = $workflow->id(); } else { // Create a new revision. @@ -186,7 +200,7 @@ protected function updateOrCreateFromEntity(EntityInterface $entity) { // Create the ContentModerationState entity for the inserted entity. $content_moderation_state->set('content_entity_revision_id', $entity_revision_id); $content_moderation_state->set('moderation_state', $moderation_state); - ContentModerationState::updateOrCreateFromEntity($content_moderation_state); + ContentModerationStateEntity::updateOrCreateFromEntity($content_moderation_state); } /** @@ -241,11 +255,13 @@ public function entityView(array &$build, EntityInterface $entity, EntityViewDis * * @param \Drupal\Core\Entity\EntityInterface $entity * The entity being saved. + * @param \Drupal\Core\Workflow\WorkflowInterface $workflow + * The workflow being applied to the entity. * * @return bool * TRUE if the default revision is published. FALSE otherwise. */ - protected function isDefaultRevisionPublished(EntityInterface $entity) { + protected function isDefaultRevisionPublished(EntityInterface $entity, WorkflowInterface $workflow) { $storage = $this->entityTypeManager->getStorage($entity->getEntityTypeId()); $default_revision = $storage->load($entity->id()); @@ -260,7 +276,7 @@ protected function isDefaultRevisionPublished(EntityInterface $entity) { $default_revision = $default_revision->getTranslation($entity->language()->getId()); } - return $default_revision && $default_revision->moderation_state->entity->isPublishedState(); + return $default_revision && $workflow->getState($default_revision->moderation_state->value)->isPublishedState(); } } diff --git a/core/modules/content_moderation/src/EntityTypeInfo.php b/core/modules/content_moderation/src/EntityTypeInfo.php index c35919e..25abf6a 100644 --- a/core/modules/content_moderation/src/EntityTypeInfo.php +++ b/core/modules/content_moderation/src/EntityTypeInfo.php @@ -9,6 +9,7 @@ use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\Entity\ContentEntityTypeInterface; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityTypeBundleInfoInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Field\BaseFieldDefinition; @@ -50,6 +51,13 @@ class EntityTypeInfo implements ContainerInjectionInterface { protected $entityTypeManager; /** + * The bundle information service. + * + * @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface + */ + protected $bundleInfo; + + /** * The current user. * * @var \Drupal\Core\Session\AccountInterface @@ -77,11 +85,16 @@ class EntityTypeInfo implements ContainerInjectionInterface { * The moderation information service. * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * Entity type manager. + * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $bundle_info + * Bundle information service. + * @param \Drupal\Core\Session\AccountInterface $current_user + * Current user. */ - public function __construct(TranslationInterface $translation, ModerationInformationInterface $moderation_information, EntityTypeManagerInterface $entity_type_manager, AccountInterface $current_user) { + public function __construct(TranslationInterface $translation, ModerationInformationInterface $moderation_information, EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $bundle_info, AccountInterface $current_user) { $this->stringTranslation = $translation; $this->moderationInfo = $moderation_information; $this->entityTypeManager = $entity_type_manager; + $this->bundleInfo = $bundle_info; $this->currentUser = $current_user; } @@ -93,6 +106,7 @@ public static function create(ContainerInterface $container) { $container->get('string_translation'), $container->get('content_moderation.moderation_information'), $container->get('entity_type.manager'), + $container->get('entity_type.bundle.info'), $container->get('current_user') ); } @@ -196,7 +210,7 @@ public function entityOperation(EntityInterface $entity) { $operations = []; $type = $entity->getEntityType(); $bundle_of = $type->getBundleOf(); - if ($this->currentUser->hasPermission('administer moderation states') && $bundle_of && + if ($this->currentUser->hasPermission('administer content moderation') && $bundle_of && $this->moderationInfo->canModerateEntitiesOfEntityType($this->entityTypeManager->getDefinition($bundle_of)) ) { $operations['manage-moderation'] = [ @@ -262,16 +276,12 @@ public function entityExtraFieldInfo() { * - bundle: The machine name of a bundle, such as "page" or "article". */ protected function getModeratedBundles() { - /** @var ConfigEntityTypeInterface $type */ - foreach ($this->filterNonRevisionableEntityTypes($this->entityTypeManager->getDefinitions()) as $type_name => $type) { - $result = $this->entityTypeManager - ->getStorage($type_name) - ->getQuery() - ->condition('third_party_settings.content_moderation.enabled', TRUE) - ->execute(); - - foreach ($result as $bundle_name) { - yield ['entity' => $type->getBundleOf(), 'bundle' => $bundle_name]; + $entity_types = array_filter($this->entityTypeManager->getDefinitions(), [$this->moderationInfo, 'canModerateEntitiesOfEntityType']); + foreach ($entity_types as $type_name => $type) { + foreach ($this->bundleInfo->getBundleInfo($type_name) as $bundle_id => $bundle) { + if ($this->moderationInfo->shouldModerateEntitiesOfBundle($type, $bundle_id)) { + yield ['entity' => $type_name, 'bundle' => $bundle_id]; + } } } } @@ -291,9 +301,9 @@ public function entityBaseFieldInfo(EntityTypeInterface $entity_type) { } $fields = []; - $fields['moderation_state'] = BaseFieldDefinition::create('entity_reference') - ->setLabel($this->t('Moderation state')) - ->setDescription($this->t('The moderation state of this piece of content.')) + $fields['moderation_state'] = BaseFieldDefinition::create('string') + ->setLabel(t('Moderation state')) + ->setDescription(t('The moderation state of this piece of content.')) ->setComputed(TRUE) ->setClass(ModerationStateFieldItemList::class) ->setSetting('target_type', 'moderation_state') diff --git a/core/modules/content_moderation/src/Form/BundleModerationConfigurationForm.php b/core/modules/content_moderation/src/Form/BundleModerationConfigurationForm.php index 88bbf7f..1c88969 100644 --- a/core/modules/content_moderation/src/Form/BundleModerationConfigurationForm.php +++ b/core/modules/content_moderation/src/Form/BundleModerationConfigurationForm.php @@ -2,12 +2,11 @@ namespace Drupal\content_moderation\Form; -use Drupal\Core\Config\Entity\ThirdPartySettingsInterface; +use Drupal\content_moderation\Plugin\WorkflowType\ContentModeration; +use Drupal\Core\Workflow\WorkflowInterface; use Drupal\Core\Entity\EntityForm; -use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Form\FormStateInterface; -use Drupal\content_moderation\Entity\ModerationState; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -50,128 +49,64 @@ public function getBaseFormId() { * {@inheritdoc} */ public function form(array $form, FormStateInterface $form_state) { - /* @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $bundle */ - $bundle = $form_state->getFormObject()->getEntity(); - $form['enable_moderation_state'] = [ - '#type' => 'checkbox', - '#title' => $this->t('Enable moderation states.'), - '#description' => $this->t('Content of this type must transition through moderation states in order to be published.'), - '#default_value' => $bundle->getThirdPartySetting('content_moderation', 'enabled', FALSE), - ]; - - // Add a special message when moderation is being disabled. - if ($bundle->getThirdPartySetting('content_moderation', 'enabled', FALSE)) { - $form['enable_moderation_state_note'] = [ - '#type' => 'item', - '#description' => $this->t('After disabling moderation, any existing forward drafts will be accessible via the "Revisions" tab.'), - '#states' => [ - 'visible' => [ - ':input[name=enable_moderation_state]' => ['checked' => FALSE], - ], - ], - ]; - } - - $states = $this->entityTypeManager->getStorage('moderation_state')->loadMultiple(); - $label = function(ModerationState $state) { - return $state->label(); - }; - - $options_published = array_map($label, array_filter($states, function(ModerationState $state) { - return $state->isPublishedState(); - })); - - $options_unpublished = array_map($label, array_filter($states, function(ModerationState $state) { - return !$state->isPublishedState(); + /* @var \Drupal\Core\Config\Entity\ConfigEntityInterface $bundle */ + $bundle = $this->getEntity(); + $bundle_of_entity_type = $this->entityTypeManager->getDefinition($bundle->getEntityType()->getBundleOf()); + /* @var \Drupal\Core\Workflow\WorkflowInterface[] $workflows */ + $workflows = $this->entityTypeManager->getStorage('workflow')->loadMultiple(); + + $options = array_map(function (WorkflowInterface $workflow) { + return $workflow->label(); + }, array_filter($workflows, function (WorkflowInterface $workflow) { + return $workflow->status() && $workflow->getTypePlugin() instanceof ContentModeration; })); - $form['allowed_moderation_states_unpublished'] = [ - '#type' => 'checkboxes', - '#title' => $this->t('Allowed moderation states (Unpublished)'), - '#description' => $this->t('The allowed unpublished moderation states this content-type can be assigned.'), - '#default_value' => $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($options_unpublished)), - '#options' => $options_unpublished, - '#required' => TRUE, - '#states' => [ - 'visible' => [ - ':input[name=enable_moderation_state]' => ['checked' => TRUE], - ], - ], + $selected_workflow = array_reduce($workflows, function ($carry, WorkflowInterface $workflow) use ($bundle_of_entity_type, $bundle) { + $plugin = $workflow->getTypePlugin(); + if ($plugin instanceof ContentModeration && $plugin->appliesToEntityTypeAndBundle($bundle_of_entity_type->id(), $bundle->id())) { + return $workflow->id(); + } + return $carry; + }); + $form['workflow'] = [ + '#type' => 'select', + '#title' => $this->t('Select the workflow to apply'), + '#default_value' => $selected_workflow, + '#options' => $options, + '#required' => FALSE, + '#empty_value' => '', ]; - $form['allowed_moderation_states_published'] = [ - '#type' => 'checkboxes', - '#title' => $this->t('Allowed moderation states (Published)'), - '#description' => $this->t('The allowed published moderation states this content-type can be assigned.'), - '#default_value' => $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($options_published)), - '#options' => $options_published, - '#required' => TRUE, - '#states' => [ - 'visible' => [ - ':input[name=enable_moderation_state]' => ['checked' => TRUE], - ], - ], + $form['original_workflow'] = [ + '#type' => 'hidden', + '#value' => $selected_workflow, ]; - // The key of the array needs to be a user-facing string so we have to fully - // render the translatable string to a real string, or else PHP errors on an - // object used as an array key. - $options = [ - $this->t('Unpublished')->render() => $options_unpublished, - $this->t('Published')->render() => $options_published, + $form['bundle'] = [ + '#type' => 'hidden', + '#value' => $bundle->id(), ]; - $form['default_moderation_state'] = [ - '#type' => 'select', - '#title' => $this->t('Default moderation state'), - '#options' => $options, - '#description' => $this->t('Select the moderation state for new content'), - '#default_value' => $bundle->getThirdPartySetting('content_moderation', 'default_moderation_state', 'draft'), - '#states' => [ - 'visible' => [ - ':input[name=enable_moderation_state]' => ['checked' => TRUE], - ], - ], + $form['entity_type'] = [ + '#type' => 'hidden', + '#value' => $bundle_of_entity_type->id(), ]; - $form['#entity_builders'][] = '::formBuilderCallback'; - - return parent::form($form, $form_state); - } - /** - * Form builder callback. - * - * @todo This should be folded into the form method. - * - * @param string $entity_type_id - * The entity type identifier. - * @param \Drupal\Core\Entity\EntityInterface $bundle - * The bundle entity updated with the submitted values. - * @param array $form - * The complete form array. - * @param \Drupal\Core\Form\FormStateInterface $form_state - * The current state of the form. - */ - public function formBuilderCallback($entity_type_id, EntityInterface $bundle, &$form, FormStateInterface $form_state) { - // @todo https://www.drupal.org/node/2779933 write a test for this. - if ($bundle instanceof ThirdPartySettingsInterface) { - $bundle->setThirdPartySetting('content_moderation', 'enabled', $form_state->getValue('enable_moderation_state')); - $bundle->setThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys(array_filter($form_state->getValue('allowed_moderation_states_published') + $form_state->getValue('allowed_moderation_states_unpublished')))); - $bundle->setThirdPartySetting('content_moderation', 'default_moderation_state', $form_state->getValue('default_moderation_state')); + // Add a special message when moderation is being disabled. + if ($selected_workflow) { + $form['enable_moderation_state_note'] = [ + '#type' => 'item', + '#description' => $this->t('After disabling moderation, any existing forward drafts will be accessible via the "Revisions" tab.'), + // @todo make this work with new select + // '#states' => [ + // 'visible' => [ + // ':input[name=enable_moderation_state]' => ['checked' => FALSE], + // ], + // ], + ]; } - } - /** - * {@inheritdoc} - */ - public function validateForm(array &$form, FormStateInterface $form_state) { - if ($form_state->getValue('enable_moderation_state')) { - $allowed = array_keys(array_filter($form_state->getValue('allowed_moderation_states_published') + $form_state->getValue('allowed_moderation_states_unpublished'))); - - if (($default = $form_state->getValue('default_moderation_state')) && !in_array($default, $allowed, TRUE)) { - $form_state->setErrorByName('default_moderation_state', $this->t('The default moderation state must be one of the allowed states.')); - } - } + return parent::form($form, $form_state); } /** @@ -180,16 +115,38 @@ public function validateForm(array &$form, FormStateInterface $form_state) { public function submitForm(array &$form, FormStateInterface $form_state) { // If moderation is enabled, revisions MUST be enabled as well. Otherwise we // can't have forward revisions. - if ($form_state->getValue('enable_moderation_state')) { - /* @var \Drupal\Core\Config\Entity\ConfigEntityTypeInterface $bundle */ - $bundle = $form_state->getFormObject()->getEntity(); + drupal_set_message($this->t('Your settings have been saved.')); + } - $this->entityTypeManager->getHandler($bundle->getEntityType()->getBundleOf(), 'moderation')->onBundleModerationConfigurationFormSubmit($bundle); + /** + * {@inheritdoc} + */ + public function save(array $form, FormStateInterface $form_state) { + $entity_type_id = $form_state->getValue('entity_type'); + $bundle_id = $form_state->getValue('bundle'); + $new_workflow_id = $form_state->getValue('workflow'); + $original_workflow_id = $form_state->getValue('original_workflow'); + if ($new_workflow_id === $original_workflow_id) { + // Nothing to do. + return; } - - parent::submitForm($form, $form_state); - - drupal_set_message($this->t('Your settings have been saved.')); + if ($original_workflow_id) { + /* @var \Drupal\Core\Workflow\WorkflowInterface $workflow */ + $workflow = $this->entityTypeManager->getStorage('workflow')->load($original_workflow_id); + $workflow->getTypePlugin()->removeEntityTypeAndBundle($entity_type_id, $bundle_id); + $workflow->save(); + } + if ($new_workflow_id) { + /* @var \Drupal\Core\Workflow\WorkflowInterface $workflow */ + $workflow = $this->entityTypeManager->getStorage('workflow')->load($new_workflow_id); + $workflow->getTypePlugin()->addEntityTypeAndBundle($entity_type_id, $bundle_id); + $workflow->save(); + } + // Clear bundle cache so workflow gets added or removed from the bundle + // information. + \Drupal::service('entity_type.bundle.info')->clearCachedBundles(); + // Clear field cache so extra field is added or removed. + \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); } } diff --git a/core/modules/content_moderation/src/Form/EntityModerationForm.php b/core/modules/content_moderation/src/Form/EntityModerationForm.php index 39baec0..132cea6 100644 --- a/core/modules/content_moderation/src/Form/EntityModerationForm.php +++ b/core/modules/content_moderation/src/Form/EntityModerationForm.php @@ -3,12 +3,11 @@ namespace Drupal\content_moderation\Form; use Drupal\Core\Entity\ContentEntityInterface; -use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Form\FormBase; use Drupal\Core\Form\FormStateInterface; -use Drupal\content_moderation\Entity\ModerationStateTransition; use Drupal\content_moderation\ModerationInformationInterface; use Drupal\content_moderation\StateTransitionValidation; +use Drupal\Core\Workflow\Transition; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -31,26 +30,16 @@ class EntityModerationForm extends FormBase { protected $validation; /** - * The entity type manager. - * - * @var \Drupal\Core\Entity\EntityTypeManagerInterface - */ - protected $entityTypeManager; - - /** * EntityModerationForm constructor. * * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info * The moderation information service. * @param \Drupal\content_moderation\StateTransitionValidation $validation * The moderation state transition validation service. - * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager - * The entity type manager. */ - public function __construct(ModerationInformationInterface $moderation_info, StateTransitionValidation $validation, EntityTypeManagerInterface $entity_type_manager) { + public function __construct(ModerationInformationInterface $moderation_info, StateTransitionValidation $validation) { $this->moderationInfo = $moderation_info; $this->validation = $validation; - $this->entityTypeManager = $entity_type_manager; } /** @@ -59,8 +48,7 @@ public function __construct(ModerationInformationInterface $moderation_info, Sta public static function create(ContainerInterface $container) { return new static( $container->get('content_moderation.moderation_information'), - $container->get('content_moderation.state_transition_validation'), - $container->get('entity_type.manager') + $container->get('content_moderation.state_transition_validation') ); } @@ -75,20 +63,21 @@ public function getFormId() { * {@inheritdoc} */ public function buildForm(array $form, FormStateInterface $form_state, ContentEntityInterface $entity = NULL) { - /** @var \Drupal\content_moderation\Entity\ModerationState $current_state */ - $current_state = $entity->moderation_state->entity; + $current_state = $entity->moderation_state->value; + $workflow = $this->moderationInfo->getWorkFlowForEntity($entity); + /** @var \Drupal\Core\Workflow\Transition[] $transitions */ $transitions = $this->validation->getValidTransitions($entity, $this->currentUser()); // Exclude self-transitions. - $transitions = array_filter($transitions, function(ModerationStateTransition $transition) use ($current_state) { - return $transition->getToState() != $current_state->id(); + $transitions = array_filter($transitions, function(Transition $transition) use ($current_state) { + return $transition->to()->id() != $current_state; }); $target_states = []; - /** @var ModerationStateTransition $transition */ + foreach ($transitions as $transition) { - $target_states[$transition->getToState()] = $transition->label(); + $target_states[$transition->to()->id()] = $transition->to()->label(); } if (!count($target_states)) { @@ -99,7 +88,7 @@ public function buildForm(array $form, FormStateInterface $form_state, ContentEn $form['current'] = [ '#type' => 'item', '#title' => $this->t('Status'), - '#markup' => $current_state->label(), + '#markup' => $workflow->getState($current_state)->label(), ]; } @@ -139,21 +128,19 @@ public function submitForm(array &$form, FormStateInterface $form_state) { // @todo should we just just be updating the content moderation state // entity? That would prevent setting the revision log. - $entity->moderation_state->target_id = $new_state; + $entity->set('moderation_state', $new_state); $entity->revision_log = $form_state->getValue('revision_log'); $entity->save(); drupal_set_message($this->t('The moderation state has been updated.')); - /** @var \Drupal\content_moderation\Entity\ModerationState $state */ - $state = $this->entityTypeManager->getStorage('moderation_state')->load($new_state); - + $new_state = $this->moderationInfo->getWorkFlowForEntity($entity)->getState($new_state); // The page we're on likely won't be visible if we just set the entity to // the default state, as we hide that latest-revision tab if there is no // forward revision. Redirect to the canonical URL instead, since that will // still exist. - if ($state->isDefaultRevisionState()) { + if ($new_state->isDefaultRevisionState()) { $form_state->setRedirectUrl($entity->toUrl('canonical')); } } diff --git a/core/modules/content_moderation/src/Form/ModerationStateForm.php b/core/modules/content_moderation/src/Form/ModerationStateForm.php deleted file mode 100644 index 32d7a48..0000000 --- a/core/modules/content_moderation/src/Form/ModerationStateForm.php +++ /dev/null @@ -1,82 +0,0 @@ -entity; - $form['label'] = array( - '#type' => 'textfield', - '#title' => $this->t('Label'), - '#maxlength' => 255, - '#default_value' => $moderation_state->label(), - '#description' => $this->t('Label for the Moderation state.'), - '#required' => TRUE, - ); - - $form['id'] = array( - '#type' => 'machine_name', - '#default_value' => $moderation_state->id(), - '#machine_name' => array( - 'exists' => [ModerationState::class, 'load'], - ), - '#disabled' => !$moderation_state->isNew(), - ); - - $form['published'] = [ - '#type' => 'checkbox', - '#title' => $this->t('Published'), - '#description' => $this->t('When content reaches this state it should be published.'), - '#default_value' => $moderation_state->isPublishedState(), - ]; - - $form['default_revision'] = [ - '#type' => 'checkbox', - '#title' => $this->t('Default revision'), - '#description' => $this->t('When content reaches this state it should be made the default revision; this is implied for published states.'), - '#default_value' => $moderation_state->isDefaultRevisionState(), - // @todo Add form #state to force "make default" on when "published" is - // on for a state. - // @see https://www.drupal.org/node/2645614 - ]; - - return $form; - } - - /** - * {@inheritdoc} - */ - public function save(array $form, FormStateInterface $form_state) { - $moderation_state = $this->entity; - $status = $moderation_state->save(); - - switch ($status) { - case SAVED_NEW: - drupal_set_message($this->t('Created the %label Moderation state.', [ - '%label' => $moderation_state->label(), - ])); - break; - - default: - drupal_set_message($this->t('Saved the %label Moderation state.', [ - '%label' => $moderation_state->label(), - ])); - } - $form_state->setRedirectUrl($moderation_state->toUrl('collection')); - } - -} diff --git a/core/modules/content_moderation/src/Form/ModerationStateTransitionDeleteForm.php b/core/modules/content_moderation/src/Form/ModerationStateTransitionDeleteForm.php deleted file mode 100644 index f153f1f..0000000 --- a/core/modules/content_moderation/src/Form/ModerationStateTransitionDeleteForm.php +++ /dev/null @@ -1,49 +0,0 @@ -t('Are you sure you want to delete %name?', array('%name' => $this->entity->label())); - } - - /** - * {@inheritdoc} - */ - public function getCancelUrl() { - return new Url('entity.moderation_state_transition.collection'); - } - - /** - * {@inheritdoc} - */ - public function getConfirmText() { - return $this->t('Delete'); - } - - /** - * {@inheritdoc} - */ - public function submitForm(array &$form, FormStateInterface $form_state) { - $this->entity->delete(); - - drupal_set_message($this->t( - 'Moderation transition %label deleted.', - ['%label' => $this->entity->label()] - )); - - $form_state->setRedirectUrl($this->getCancelUrl()); - } - -} diff --git a/core/modules/content_moderation/src/Form/ModerationStateTransitionForm.php b/core/modules/content_moderation/src/Form/ModerationStateTransitionForm.php deleted file mode 100644 index 8322c18..0000000 --- a/core/modules/content_moderation/src/Form/ModerationStateTransitionForm.php +++ /dev/null @@ -1,151 +0,0 @@ -entityTypeManager = $entity_type_manager; - $this->queryFactory = $query_factory; - } - - /** - * {@inheritdoc} - */ - public static function create(ContainerInterface $container) { - return new static($container->get('entity_type.manager'), $container->get('entity.query')); - } - - /** - * {@inheritdoc} - */ - public function form(array $form, FormStateInterface $form_state) { - $form = parent::form($form, $form_state); - - /* @var \Drupal\content_moderation\ModerationStateTransitionInterface $moderation_state_transition */ - $moderation_state_transition = $this->entity; - $form['label'] = [ - '#type' => 'textfield', - '#title' => $this->t('Label'), - '#maxlength' => 255, - '#default_value' => $moderation_state_transition->label(), - '#description' => $this->t('Label for the Moderation state transition.'), - '#required' => TRUE, - ]; - - $form['id'] = [ - '#type' => 'machine_name', - '#default_value' => $moderation_state_transition->id(), - '#machine_name' => [ - 'exists' => '\Drupal\content_moderation\Entity\ModerationStateTransition::load', - ], - '#disabled' => !$moderation_state_transition->isNew(), - ]; - - $options = []; - foreach ($this->entityTypeManager->getStorage('moderation_state') - ->loadMultiple() as $moderation_state) { - $options[$moderation_state->id()] = $moderation_state->label(); - } - - $form['container'] = [ - '#type' => 'container', - '#attributes' => [ - 'class' => ['container-inline'], - ], - ]; - - $form['container']['stateFrom'] = [ - '#type' => 'select', - '#title' => $this->t('Transition from'), - '#options' => $options, - '#required' => TRUE, - '#empty_option' => $this->t('-- Select --'), - '#default_value' => $moderation_state_transition->getFromState(), - ]; - - $form['container']['stateTo'] = [ - '#type' => 'select', - '#options' => $options, - '#required' => TRUE, - '#title' => $this->t('Transition to'), - '#empty_option' => $this->t('-- Select --'), - '#default_value' => $moderation_state_transition->getToState(), - ]; - - // Make sure there's always at least a wide enough delta on weight to cover - // the current value or the total number of transitions. That way we - // never end up forcing a transition to change its weight needlessly. - $num_transitions = $this->queryFactory->get('moderation_state_transition') - ->count() - ->execute(); - $delta = max(abs($moderation_state_transition->getWeight()), $num_transitions); - - $form['weight'] = [ - '#type' => 'weight', - '#delta' => $delta, - '#options' => $options, - '#title' => $this->t('Weight'), - '#default_value' => $moderation_state_transition->getWeight(), - '#description' => $this->t('Orders the transitions in moderation forms and the administrative listing. Heavier items will sink and the lighter items will be positioned nearer the top.'), - ]; - - return $form; - } - - /** - * {@inheritdoc} - */ - public function save(array $form, FormStateInterface $form_state) { - $moderation_state_transition = $this->entity; - $status = $moderation_state_transition->save(); - - switch ($status) { - case SAVED_NEW: - drupal_set_message($this->t('Created the %label Moderation state transition.', [ - '%label' => $moderation_state_transition->label(), - ])); - break; - - default: - drupal_set_message($this->t('Saved the %label Moderation state transition.', [ - '%label' => $moderation_state_transition->label(), - ])); - } - $form_state->setRedirectUrl($moderation_state_transition->toUrl('collection')); - } - -} diff --git a/core/modules/content_moderation/src/ModerationInformation.php b/core/modules/content_moderation/src/ModerationInformation.php index ed33902..e8ebf39 100644 --- a/core/modules/content_moderation/src/ModerationInformation.php +++ b/core/modules/content_moderation/src/ModerationInformation.php @@ -4,6 +4,7 @@ use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityTypeBundleInfoInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; @@ -20,15 +21,23 @@ class ModerationInformation implements ModerationInformationInterface { protected $entityTypeManager; /** + * The bundle information service. + * + * @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface + */ + protected $bundleInfo; + + /** * Creates a new ModerationInformation instance. * * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * The entity type manager. - * @param \Drupal\Core\Session\AccountInterface $current_user - * The current user. + * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $bundle_info + * The bundle information service. */ - public function __construct(EntityTypeManagerInterface $entity_type_manager) { + public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $bundle_info) { $this->entityTypeManager = $entity_type_manager; + $this->bundleInfo = $bundle_info; } /** @@ -54,10 +63,8 @@ public function canModerateEntitiesOfEntityType(EntityTypeInterface $entity_type */ public function shouldModerateEntitiesOfBundle(EntityTypeInterface $entity_type, $bundle) { if ($this->canModerateEntitiesOfEntityType($entity_type)) { - $bundle_entity = $this->entityTypeManager->getStorage($entity_type->getBundleEntityType())->load($bundle); - if ($bundle_entity) { - return $bundle_entity->getThirdPartySetting('content_moderation', 'enabled', FALSE); - } + $bundles = $this->bundleInfo->getBundleInfo($entity_type->id()); + return isset($bundles[$bundle]['workflow']); } return FALSE; } @@ -123,10 +130,22 @@ public function hasForwardRevision(ContentEntityInterface $entity) { * {@inheritdoc} */ public function isLiveRevision(ContentEntityInterface $entity) { + $workflow = $this->getWorkFlowForEntity($entity); return $this->isLatestRevision($entity) && $entity->isDefaultRevision() - && $entity->moderation_state->entity - && $entity->moderation_state->entity->isPublishedState(); + && $entity->moderation_state->value + && $workflow->getState($entity->moderation_state->value)->isPublishedState(); + } + + /** + * {@inheritdoc} + */ + public function getWorkFlowForEntity(ContentEntityInterface $entity) { + $bundles = $this->bundleInfo->getBundleInfo($entity->getEntityTypeId()); + if (isset($bundles[$entity->bundle()]['workflow'])) { + return $this->entityTypeManager->getStorage('workflow')->load($bundles[$entity->bundle()]['workflow']); + }; + return NULL; } } diff --git a/core/modules/content_moderation/src/ModerationInformationInterface.php b/core/modules/content_moderation/src/ModerationInformationInterface.php index 95a658e..c168740 100644 --- a/core/modules/content_moderation/src/ModerationInformationInterface.php +++ b/core/modules/content_moderation/src/ModerationInformationInterface.php @@ -126,4 +126,15 @@ public function hasForwardRevision(ContentEntityInterface $entity); */ public function isLiveRevision(ContentEntityInterface $entity); + /** + * Gets the workflow for the given content entity. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * The content entity to get the workflow for. + * + * @return \Drupal\Core\Workflow\WorkflowInterface|null + * The workflow entity. NULL if there is no workflow. + */ + public function getWorkFlowForEntity(ContentEntityInterface $entity); + } diff --git a/core/modules/content_moderation/src/ModerationStateAccessControlHandler.php b/core/modules/content_moderation/src/ModerationStateAccessControlHandler.php deleted file mode 100644 index b2c86d7..0000000 --- a/core/modules/content_moderation/src/ModerationStateAccessControlHandler.php +++ /dev/null @@ -1,31 +0,0 @@ -orIf($admin_access); - } - - return $admin_access; - } - -} diff --git a/core/modules/content_moderation/src/ModerationStateInterface.php b/core/modules/content_moderation/src/ModerationStateInterface.php deleted file mode 100644 index 99f664f..0000000 --- a/core/modules/content_moderation/src/ModerationStateInterface.php +++ /dev/null @@ -1,28 +0,0 @@ -t('Moderation state'); - $header['id'] = $this->t('Machine name'); - - return $header + parent::buildHeader(); - } - - /** - * {@inheritdoc} - */ - public function buildRow(EntityInterface $entity) { - $row['label'] = $entity->label(); - $row['id']['#markup'] = $entity->id(); - - return $row + parent::buildRow($entity); - } - -} diff --git a/core/modules/content_moderation/src/ModerationStateTransitionInterface.php b/core/modules/content_moderation/src/ModerationStateTransitionInterface.php deleted file mode 100644 index 91b5b13..0000000 --- a/core/modules/content_moderation/src/ModerationStateTransitionInterface.php +++ /dev/null @@ -1,36 +0,0 @@ -get('entity.manager')->getStorage($entity_type->id()), - $container->get('entity.manager')->getStorage('moderation_state'), - $container->get('entity.manager')->getStorage('user_role') - ); - } - - /** - * Constructs a new ModerationStateTransitionListBuilder. - * - * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type - * Entity Type. - * @param \Drupal\Core\Entity\EntityStorageInterface $transition_storage - * Moderation state transition entity storage. - * @param \Drupal\Core\Entity\EntityStorageInterface $state_storage - * Moderation state entity storage. - * @param \Drupal\user\RoleStorageInterface $role_storage - * The role storage. - */ - public function __construct(EntityTypeInterface $entity_type, EntityStorageInterface $transition_storage, EntityStorageInterface $state_storage, RoleStorageInterface $role_storage) { - parent::__construct($entity_type, $transition_storage); - $this->stateStorage = $state_storage; - $this->roleStorage = $role_storage; - } - - /** - * {@inheritdoc} - */ - public function getFormId() { - return 'content_moderation_transition_list'; - } - - /** - * {@inheritdoc} - */ - public function buildHeader() { - $header['to'] = $this->t('To state'); - $header['label'] = $this->t('Button label'); - $header['roles'] = $this->t('Allowed roles'); - - return $header + parent::buildHeader(); - } - - /** - * {@inheritdoc} - */ - public function buildRow(EntityInterface $entity) { - $row['to']['#markup'] = $this->stateStorage->load($entity->getToState())->label(); - $row['label'] = $entity->label(); - $row['roles']['#markup'] = implode(', ', user_role_names(FALSE, 'use ' . $entity->id() . ' transition')); - - return $row + parent::buildRow($entity); - } - - /** - * {@inheritdoc} - */ - public function render() { - $build = parent::render(); - - $build['item'] = [ - '#type' => 'item', - '#markup' => $this->t('On this screen you can define transitions. Every time an entity is saved, it undergoes a transition. It is not possible to save an entity if it tries do a transition not defined here. Transitions do not necessarily mean a state change, it is possible to transition from a state to the same state but that transition needs to be defined here as well.'), - '#weight' => -5, - ]; - - return $build; - } - - /** - * {@inheritdoc} - */ - public function buildForm(array $form, FormStateInterface $form_state) { - $this->entities = $this->load(); - - // Get all the moderation states and sort them by weight. - $states = $this->stateStorage->loadMultiple(); - uasort($states, array($this->entityType->getClass(), 'sort')); - - /** @var \Drupal\content_moderation\ModerationStateTransitionInterface $entity */ - $groups = array_fill_keys(array_keys($states), []); - foreach ($this->entities as $entity) { - $groups[$entity->getFromState()][] = $entity; - } - - foreach ($groups as $group_name => $entities) { - $form[$group_name] = [ - '#type' => 'details', - '#title' => $this->t('From @state to...', ['@state' => $states[$group_name]->label()]), - // Make sure that the first group is always open. - '#open' => $group_name === array_keys($groups)[0], - ]; - - $form[$group_name][$this->entitiesKey] = array( - '#type' => 'table', - '#header' => $this->buildHeader(), - '#empty' => t('There is no @label yet.', array('@label' => $this->entityType->getLabel())), - '#tabledrag' => array( - array( - 'action' => 'order', - 'relationship' => 'sibling', - 'group' => 'weight', - ), - ), - ); - - $delta = 10; - // Change the delta of the weight field if have more than 20 entities. - if (!empty($this->weightKey)) { - $count = count($this->entities); - if ($count > 20) { - $delta = ceil($count / 2); - } - } - foreach ($entities as $entity) { - $row = $this->buildRow($entity); - if (isset($row['label'])) { - $row['label'] = array('#markup' => $row['label']); - } - if (isset($row['weight'])) { - $row['weight']['#delta'] = $delta; - } - $form[$group_name][$this->entitiesKey][$entity->id()] = $row; - } - } - - $form['actions']['#type'] = 'actions'; - $form['actions']['submit'] = array( - '#type' => 'submit', - '#value' => t('Save order'), - '#button_type' => 'primary', - ); - - return $form; - } - -} diff --git a/core/modules/content_moderation/src/Permissions.php b/core/modules/content_moderation/src/Permissions.php index 027684c..f9f377e 100644 --- a/core/modules/content_moderation/src/Permissions.php +++ b/core/modules/content_moderation/src/Permissions.php @@ -3,8 +3,7 @@ namespace Drupal\content_moderation; use Drupal\Core\StringTranslation\StringTranslationTrait; -use Drupal\content_moderation\Entity\ModerationState; -use Drupal\content_moderation\Entity\ModerationStateTransition; +use Drupal\Core\Workflow\Entity\Workflow; /** * Defines a class for dynamic permissions based on transitions. @@ -22,19 +21,17 @@ class Permissions { public function transitionPermissions() { // @todo https://www.drupal.org/node/2779933 write a test for this. $perms = []; - /* @var \Drupal\content_moderation\ModerationStateInterface[] $states */ - $states = ModerationState::loadMultiple(); - /* @var \Drupal\content_moderation\ModerationStateTransitionInterface $transition */ - foreach (ModerationStateTransition::loadMultiple() as $id => $transition) { - $perms['use ' . $id . ' transition'] = [ - 'title' => $this->t('Use the %transition_name transition', [ - '%transition_name' => $transition->label(), - ]), - 'description' => $this->t('Move content from %from state to %to state.', [ - '%from' => $states[$transition->getFromState()]->label(), - '%to' => $states[$transition->getToState()]->label(), - ]), - ]; + + /** @var \Drupal\Core\Workflow\WorkflowInterface $workflow */ + foreach (Workflow::loadMultipleByType('content_moderation') as $id => $workflow) { + foreach ($workflow->getTransitions() as $transition) { + $perms['use ' . $workflow->id() . ' transition from ' . $transition->from()->id() . ' to ' . $transition->to()->id()] = [ + 'title' => $this->t('Move content from %from state to %to state.', [ + '%from' => $transition->from()->label(), + '%to' => $transition->to()->label(), + ]), + ]; + } } return $perms; diff --git a/core/modules/content_moderation/src/Plugin/Field/FieldFormatter/ContentModerationStateFormatter.php b/core/modules/content_moderation/src/Plugin/Field/FieldFormatter/ContentModerationStateFormatter.php new file mode 100644 index 0000000..c3b6eeb --- /dev/null +++ b/core/modules/content_moderation/src/Plugin/Field/FieldFormatter/ContentModerationStateFormatter.php @@ -0,0 +1,49 @@ +getEntity(); + $workflow = $entity->workflow->entity; + foreach ($items as $delta => $item) { + if (!$item->isEmpty()) { + $elements[$delta] = [ + '#markup' => $workflow->getState($item->value)->label(), + ]; + } + } + + return $elements; + } + + /** + * {@inheritdoc} + */ + public static function isApplicable(FieldDefinitionInterface $field_definition) { + // By default, formatters are available for all fields. + return TRUE; + } + +} diff --git a/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php b/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php index d6dc89d..239ab30 100644 --- a/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php +++ b/core/modules/content_moderation/src/Plugin/Field/FieldWidget/ModerationStateWidget.php @@ -3,9 +3,7 @@ namespace Drupal\content_moderation\Plugin\Field\FieldWidget; use Drupal\Core\Entity\ContentEntityInterface; -use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; -use Drupal\Core\Entity\Query\QueryInterface; use Drupal\Core\Field\FieldDefinitionInterface; use Drupal\Core\Field\FieldItemListInterface; use Drupal\Core\Field\Plugin\Field\FieldWidget\OptionsSelectWidget; @@ -23,7 +21,7 @@ * id = "moderation_state_default", * label = @Translation("Moderation state"), * field_types = { - * "entity_reference" + * "string" * } * ) */ @@ -37,20 +35,6 @@ class ModerationStateWidget extends OptionsSelectWidget implements ContainerFact protected $currentUser; /** - * Moderation state transition entity query. - * - * @var \Drupal\Core\Entity\Query\QueryInterface - */ - protected $moderationStateTransitionEntityQuery; - - /** - * Moderation state storage. - * - * @var \Drupal\Core\Entity\EntityStorageInterface - */ - protected $moderationStateStorage; - - /** * Moderation information service. * * @var \Drupal\content_moderation\ModerationInformation @@ -65,13 +49,6 @@ class ModerationStateWidget extends OptionsSelectWidget implements ContainerFact protected $entityTypeManager; /** - * Moderation state transition storage. - * - * @var \Drupal\Core\Entity\EntityStorageInterface - */ - protected $moderationStateTransitionStorage; - - /** * Moderation state transition validation service. * * @var \Drupal\content_moderation\StateTransitionValidation @@ -95,22 +72,13 @@ class ModerationStateWidget extends OptionsSelectWidget implements ContainerFact * Current user service. * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * Entity type manager. - * @param \Drupal\Core\Entity\EntityStorageInterface $moderation_state_storage - * Moderation state storage. - * @param \Drupal\Core\Entity\EntityStorageInterface $moderation_state_transition_storage - * Moderation state transition storage. - * @param \Drupal\Core\Entity\Query\QueryInterface $entity_query - * Moderation transition entity query service. * @param \Drupal\content_moderation\ModerationInformation $moderation_information * Moderation information service. * @param \Drupal\content_moderation\StateTransitionValidation $validator * Moderation state transition validation service */ - public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, AccountInterface $current_user, EntityTypeManagerInterface $entity_type_manager, EntityStorageInterface $moderation_state_storage, EntityStorageInterface $moderation_state_transition_storage, QueryInterface $entity_query, ModerationInformation $moderation_information, StateTransitionValidation $validator) { + public function __construct($plugin_id, $plugin_definition, FieldDefinitionInterface $field_definition, array $settings, array $third_party_settings, AccountInterface $current_user, EntityTypeManagerInterface $entity_type_manager, ModerationInformation $moderation_information, StateTransitionValidation $validator) { parent::__construct($plugin_id, $plugin_definition, $field_definition, $settings, $third_party_settings); - $this->moderationStateTransitionEntityQuery = $entity_query; - $this->moderationStateTransitionStorage = $moderation_state_transition_storage; - $this->moderationStateStorage = $moderation_state_storage; $this->entityTypeManager = $entity_type_manager; $this->currentUser = $current_user; $this->moderationInformation = $moderation_information; @@ -129,9 +97,6 @@ public static function create(ContainerInterface $container, array $configuratio $configuration['third_party_settings'], $container->get('current_user'), $container->get('entity_type.manager'), - $container->get('entity_type.manager')->getStorage('moderation_state'), - $container->get('entity_type.manager')->getStorage('moderation_state_transition'), - $container->get('entity.query')->get('moderation_state_transition', 'AND'), $container->get('content_moderation.moderation_information'), $container->get('content_moderation.state_transition_validation') ); @@ -151,19 +116,18 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen return $element + ['#access' => FALSE]; } - $default = $items->get($delta)->value ?: $bundle_entity->getThirdPartySetting('content_moderation', 'default_moderation_state', FALSE); - /** @var \Drupal\content_moderation\ModerationStateInterface $default_state */ - $default_state = $this->entityTypeManager->getStorage('moderation_state')->load($default); - if (!$default || !$default_state) { + $workflow = $this->moderationInformation->getWorkFlowForEntity($entity); + $default = $items->get($delta)->value ? $workflow->getState($items->get($delta)->value) : $workflow->getInitialState(); + if (!$default) { throw new \UnexpectedValueException(sprintf('The %s bundle has an invalid moderation state configuration, moderation states are enabled but no default is set.', $bundle_entity->label())); } + /** @var \Drupal\Core\Workflow\Transition[] $transitions */ $transitions = $this->validator->getValidTransitions($entity, $this->currentUser); $target_states = []; - /** @var \Drupal\content_moderation\Entity\ModerationStateTransition $transition */ foreach ($transitions as $transition) { - $target_states[$transition->getToState()] = $transition->label(); + $target_states[$transition->to()->id()] = $transition->label(); } // @todo https://www.drupal.org/node/2779933 write a test for this. @@ -171,8 +135,8 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen '#access' => FALSE, '#type' => 'select', '#options' => $target_states, - '#default_value' => $default, - '#published' => $default ? $default_state->isPublishedState() : FALSE, + '#default_value' => $default->id(), + '#published' => $default->isPublishedState(), '#key_column' => $this->column, ]; $element['#element_validate'][] = array(get_class($this), 'validateElement'); @@ -197,7 +161,7 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen public static function updateStatus($entity_type_id, ContentEntityInterface $entity, array $form, FormStateInterface $form_state) { $element = $form_state->getTriggeringElement(); if (isset($element['#moderation_state'])) { - $entity->moderation_state->target_id = $element['#moderation_state']; + $entity->moderation_state->value = $element['#moderation_state']; } } diff --git a/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php b/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php index c32521c..036f346 100644 --- a/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php +++ b/core/modules/content_moderation/src/Plugin/Field/ModerationStateFieldItemList.php @@ -2,8 +2,7 @@ namespace Drupal\content_moderation\Plugin\Field; -use Drupal\content_moderation\Entity\ModerationState; -use Drupal\Core\Field\EntityReferenceFieldItemList; +use Drupal\Core\Field\FieldItemList; /** * A computed field that provides a content entity's moderation state. @@ -11,19 +10,20 @@ * It links content entities to a moderation state configuration entity via a * moderation state content entity. */ -class ModerationStateFieldItemList extends EntityReferenceFieldItemList { +class ModerationStateFieldItemList extends FieldItemList { /** * Gets the moderation state entity linked to a content entity revision. * - * @return \Drupal\content_moderation\ModerationStateInterface|null - * The moderation state configuration entity linked to a content entity - * revision. + * @return string|null + * The moderation state linked to a content entity revision. */ protected function getModerationState() { $entity = $this->getEntity(); - if (!\Drupal::service('content_moderation.moderation_information')->shouldModerateEntitiesOfBundle($entity->getEntityType(), $entity->bundle())) { + /** @var \Drupal\content_moderation\ModerationInformationInterface $moderation_info */ + $moderation_info = \Drupal::service('content_moderation.moderation_information'); + if (!$moderation_info->shouldModerateEntitiesOfBundle($entity->getEntityType(), $entity->bundle())) { return NULL; } @@ -32,6 +32,7 @@ protected function getModerationState() { ->condition('content_entity_type_id', $entity->getEntityTypeId()) ->condition('content_entity_id', $entity->id()) ->condition('content_entity_revision_id', $entity->getRevisionId()) + ->condition('workflow', $moderation_info->getWorkFlowForEntity($entity)->id()) ->allRevisions() ->sort('revision_id', 'DESC') ->execute(); @@ -53,17 +54,15 @@ protected function getModerationState() { } } - return $content_moderation_state->get('moderation_state')->entity; + return $content_moderation_state->get('moderation_state')->value; } } // It is possible that the bundle does not exist at this point. For example, // the node type form creates a fake Node entity to get default values. // @see \Drupal\node\NodeTypeForm::form() - $bundle_entity = \Drupal::entityTypeManager() - ->getStorage($entity->getEntityType()->getBundleEntityType()) - ->load($entity->bundle()); - if ($bundle_entity && ($default = $bundle_entity->getThirdPartySetting('content_moderation', 'default_moderation_state'))) { - return ModerationState::load($default); + $workflow = $moderation_info->getWorkFlowForEntity($entity); + if ($workflow) { + return $workflow->getInitialState()->id(); } } @@ -93,10 +92,11 @@ protected function computeModerationFieldItemList() { // Compute the value of the moderation state. $index = 0; if (!isset($this->list[$index]) || $this->list[$index]->isEmpty()) { + $moderation_state = $this->getModerationState(); // Do not store NULL values in the static cache. if ($moderation_state) { - $this->list[$index] = $this->createItem($index, ['entity' => $moderation_state]); + $this->list[$index] = $this->createItem($index, $moderation_state); } } } diff --git a/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php index ca75604..d90e19e 100644 --- a/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php +++ b/core/modules/content_moderation/src/Plugin/Validation/Constraint/ModerationStateConstraintValidator.php @@ -2,7 +2,6 @@ namespace Drupal\content_moderation\Plugin\Validation\Constraint; -use Drupal\content_moderation\Entity\ModerationState as ModerationStateEntity; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; @@ -93,21 +92,13 @@ public function validate($value, Constraint $constraint) { $original_entity = $original_entity->getTranslation($entity->language()->getId()); } - if ($entity->moderation_state->target_id) { - $new_state_id = $entity->moderation_state->target_id; - } - else { - $new_state_id = $default = $this->entityTypeManager - ->getStorage($entity->getEntityType()->getBundleEntityType())->load($entity->bundle()) - ->getThirdPartySetting('content_moderation', 'default_moderation_state'); - } - if ($new_state_id) { - $new_state = ModerationStateEntity::load($new_state_id); - } - // @todo - what if $new_state_id references something that does not exist or + $workflow = $this->moderationInformation->getWorkFlowForEntity($entity); + $new_state = $workflow->getState($entity->moderation_state->value) ?: $workflow->getInitialState(); + $original_state = $workflow->getState($original_entity->moderation_state->value); + // @todo - what if $new_state references something that does not exist or // is null. - if (!$this->validation->isTransitionAllowed($original_entity->moderation_state->entity, $new_state)) { - $this->context->addViolation($constraint->message, ['%from' => $original_entity->moderation_state->entity->label(), '%to' => $new_state->label()]); + if (!$original_state->canTransitionTo($new_state->id())) { + $this->context->addViolation($constraint->message, ['%from' => $original_state->label(), '%to' => $new_state->label()]); } } @@ -126,9 +117,9 @@ public function validate($value, Constraint $constraint) { protected function isFirstTimeModeration(EntityInterface $entity) { $original_entity = $this->moderationInformation->getLatestRevision($entity->getEntityTypeId(), $entity->id()); - $original_id = $original_entity->moderation_state->target_id; + $original_id = $original_entity->moderation_state; - return !($entity->moderation_state->target_id && $original_entity && $original_id); + return !($entity->moderation_state && $original_entity && $original_id); } } diff --git a/core/modules/content_moderation/src/Plugin/WorkflowType/ContentModeration.php b/core/modules/content_moderation/src/Plugin/WorkflowType/ContentModeration.php new file mode 100644 index 0000000..3db44f1 --- /dev/null +++ b/core/modules/content_moderation/src/Plugin/WorkflowType/ContentModeration.php @@ -0,0 +1,164 @@ +configuration['states'][$state->id()])) { + $state = new ContentModerationState($state, $this->configuration['states'][$state->id()]['published'], $this->configuration['states'][$state->id()]['default_revision']); + } + else { + $state = new ContentModerationState($state); + } + return $state; + } + + /** + * {@inheritdoc} + */ + public function buildStateConfigurationForm(FormStateInterface $form_state, WorkflowInterface $workflow, StateInterface $state = NULL) { + /** @var \Drupal\content_moderation\ContentModerationState $state */ + $form = []; + $form['published'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Published'), + '#description' => $this->t('When content reaches this state it should be published.'), + '#default_value' => isset($state) ? $state->isPublishedState() : FALSE, + ]; + + $form['default_revision'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Default revision'), + '#description' => $this->t('When content reaches this state it should be made the default revision; this is implied for published states.'), + '#default_value' => isset($state) ? $state->isDefaultRevisionState() : FALSE, + // @todo Add form #state to force "make default" on when "published" is + // on for a state. + // @see https://www.drupal.org/node/2645614 + ]; + return $form; + } + + /** + * Gets the entity types the workflow is applied to. + * + * @return string[] + * The entity types the workflow is applied to. + */ + public function getEntityTypes() { + return array_keys($this->configuration['entity_types']); + } + + /** + * Gets the bundles of the entity type the workflow is applied to. + * + * @param string $entity_type_id + * The entity type ID to get the bundles for. + * + * @return string[] + * The bundles of the entity type the workflow is applied to. + */ + public function getBundlesForEntityType($entity_type_id) { + return $this->configuration['entity_types'][$entity_type_id]; + } + + /** + * Checks if the workflow applies to the supplied entity type and bundle. + * + * @return bool + * TRUE if the workflow applies to the supplied entity type and bundle. + * FALSE if not. + */ + public function appliesToEntityTypeAndBundle($entity_type_id, $bundle_id) { + if (isset($this->configuration['entity_types'][$entity_type_id])) { + return in_array($bundle_id, $this->configuration['entity_types'][$entity_type_id], TRUE); + } + return FALSE; + } + + /** + * Removes an entity type ID / bundle ID from the workflow. + * + * @param string $entity_type_id + * The entity type ID to remove. + * @param string $bundle_id + * The bundle ID to remove. + */ + public function removeEntityTypeAndBundle($entity_type_id, $bundle_id) { + $key = array_search($bundle_id, $this->configuration['entity_types'][$entity_type_id], TRUE); + if ($key !== FALSE) { + unset($this->configuration['entity_types'][$entity_type_id][$key]); + if (empty($this->configuration['entity_types'][$entity_type_id])) { + unset($this->configuration['entity_types'][$entity_type_id]); + } + else { + $this->configuration['entity_types'][$entity_type_id] = array_values($this->configuration['entity_types'][$entity_type_id]); + } + } + } + + /** + * Add an entity type ID / bundle ID to the workflow. + * + * @param string $entity_type_id + * The entity type ID to add. + * @param string $bundle_id + * The bundle ID to add. + */ + public function addEntityTypeAndBundle($entity_type_id, $bundle_id) { + if (!$this->appliesToEntityTypeAndBundle($entity_type_id, $bundle_id)) { + $this->configuration['entity_types'][$entity_type_id][] = $bundle_id; + natsort($this->configuration['entity_types'][$entity_type_id]); + } + } + + /** + * {@inheritDoc} + */ + public function defaultConfiguration() { + $default_configuration = parent::defaultConfiguration(); + $default_configuration['entity_types'] = []; + return $default_configuration; + } + + /** + * @inheritDoc + */ + public function calculateDependencies() { + return []; + // TODO: Implement calculateDependencies() method. + } + +} diff --git a/core/modules/content_moderation/src/Routing/EntityTypeModerationRouteProvider.php b/core/modules/content_moderation/src/Routing/EntityTypeModerationRouteProvider.php index c722a67..d1dcd2b 100644 --- a/core/modules/content_moderation/src/Routing/EntityTypeModerationRouteProvider.php +++ b/core/modules/content_moderation/src/Routing/EntityTypeModerationRouteProvider.php @@ -47,7 +47,7 @@ protected function getModerationFormRoute(EntityTypeInterface $entity_type) { '_entity_form' => "{$entity_type_id}.moderation", '_title' => 'Moderation', ]) - ->setRequirement('_permission', 'administer moderation states') + ->setRequirement('_permission', 'administer content moderation') ->setOption('parameters', [ $entity_type_id => ['type' => 'entity:' . $entity_type_id], ]); diff --git a/core/modules/content_moderation/src/StateTransitionValidation.php b/core/modules/content_moderation/src/StateTransitionValidation.php index 2e2a4e2..137f9b5 100644 --- a/core/modules/content_moderation/src/StateTransitionValidation.php +++ b/core/modules/content_moderation/src/StateTransitionValidation.php @@ -3,10 +3,8 @@ namespace Drupal\content_moderation; use Drupal\Core\Entity\ContentEntityInterface; -use Drupal\Core\Entity\EntityTypeManagerInterface; -use Drupal\Core\Entity\Query\QueryFactory; use Drupal\Core\Session\AccountInterface; -use Drupal\content_moderation\Entity\ModerationStateTransition; +use Drupal\Core\Workflow\Transition; /** * Validates whether a certain state transition is allowed. @@ -14,18 +12,11 @@ class StateTransitionValidation implements StateTransitionValidationInterface { /** - * Entity type manager. + * The moderation information service. * - * @var \Drupal\Core\Entity\EntityTypeManagerInterface + * @var \Drupal\content_moderation\ModerationInformationInterface */ - protected $entityTypeManager; - - /** - * Entity query factory. - * - * @var \Drupal\Core\Entity\Query\QueryFactory - */ - protected $queryFactory; + protected $moderationInfo; /** * Stores the possible state transitions. @@ -37,211 +28,23 @@ class StateTransitionValidation implements StateTransitionValidationInterface { /** * Constructs a new StateTransitionValidation. * - * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager - * The entity type manager service. - * @param \Drupal\Core\Entity\Query\QueryFactory $query_factory - * The entity query factory. - */ - public function __construct(EntityTypeManagerInterface $entity_type_manager, QueryFactory $query_factory) { - $this->entityTypeManager = $entity_type_manager; - $this->queryFactory = $query_factory; - } - - /** - * Computes a mapping of possible transitions. - * - * This method is uncached and will recalculate the list on every request. - * In most cases you want to use getPossibleTransitions() instead. - * - * @see static::getPossibleTransitions() - * - * @return array[] - * An array containing all possible transitions. Each entry is keyed by the - * "from" state, and the value is an array of all legal "to" states based - * on the currently defined transition objects. - */ - protected function calculatePossibleTransitions() { - $transitions = $this->transitionStorage()->loadMultiple(); - - $possible_transitions = []; - /** @var \Drupal\content_moderation\ModerationStateTransitionInterface $transition */ - foreach ($transitions as $transition) { - $possible_transitions[$transition->getFromState()][] = $transition->getToState(); - } - return $possible_transitions; - } - - /** - * Returns a mapping of possible transitions. - * - * @return array[] - * An array containing all possible transitions. Each entry is keyed by the - * "from" state, and the value is an array of all legal "to" states based - * on the currently defined transition objects. + * @param \Drupal\content_moderation\ModerationInformationInterface $moderation_info + * The moderation information service. */ - protected function getPossibleTransitions() { - if (empty($this->possibleTransitions)) { - $this->possibleTransitions = $this->calculatePossibleTransitions(); - } - return $this->possibleTransitions; - } - - /** - * {@inheritdoc} - */ - public function getValidTransitionTargets(ContentEntityInterface $entity, AccountInterface $user) { - $bundle = $this->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle()); - - $states_for_bundle = $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', []); - - /** @var \Drupal\content_moderation\Entity\ModerationState $current_state */ - $current_state = $entity->moderation_state->entity; - - $all_transitions = $this->getPossibleTransitions(); - $destination_ids = $all_transitions[$current_state->id()]; - - $destination_ids = array_intersect($states_for_bundle, $destination_ids); - $destinations = $this->entityTypeManager->getStorage('moderation_state')->loadMultiple($destination_ids); - - return array_filter($destinations, function(ModerationStateInterface $destination_state) use ($current_state, $user) { - return $this->userMayTransition($current_state, $destination_state, $user); - }); + public function __construct(ModerationInformationInterface $moderation_info) { + $this->moderationInfo = $moderation_info; } /** * {@inheritdoc} */ public function getValidTransitions(ContentEntityInterface $entity, AccountInterface $user) { - $bundle = $this->loadBundleEntity($entity->getEntityType()->getBundleEntityType(), $entity->bundle()); - - /** @var \Drupal\content_moderation\Entity\ModerationState $current_state */ - $current_state = $entity->moderation_state->entity; - $current_state_id = $current_state ? $current_state->id() : $bundle->getThirdPartySetting('content_moderation', 'default_moderation_state'); - - // Determine the states that are legal on this bundle. - $legal_bundle_states = $bundle->getThirdPartySetting('content_moderation', 'allowed_moderation_states', []); + $workflow = $this->moderationInfo->getWorkFlowForEntity($entity); + $current_state = $entity->moderation_state->value ? $workflow->getState($entity->moderation_state->value) : $workflow->getInitialState(); - // Legal transitions include those that are possible from the current state, - // filtered by those whose target is legal on this bundle and that the - // user has access to execute. - $transitions = array_filter($this->getTransitionsFrom($current_state_id), function(ModerationStateTransition $transition) use ($legal_bundle_states, $user) { - return in_array($transition->getToState(), $legal_bundle_states, TRUE) - && $user->hasPermission('use ' . $transition->id() . ' transition'); + return array_filter($current_state->getTransitions(), function(Transition $transition) use ($workflow, $user) { + return $user->hasPermission('use ' . $workflow->id() . ' transition from ' . $transition->from()->id() . ' to ' . $transition->to()->id()); }); - - return $transitions; - } - - /** - * Returns a list of possible transitions from a given state. - * - * This list is based only on those transitions that exist, not what - * transitions are legal in a given context. - * - * @param string $state_name - * The machine name of the state from which we are transitioning. - * - * @return ModerationStateTransition[] - * A list of possible transitions from a given state. - */ - protected function getTransitionsFrom($state_name) { - $result = $this->transitionStateQuery() - ->condition('stateFrom', $state_name) - ->sort('weight') - ->execute(); - - return $this->transitionStorage()->loadMultiple($result); - } - - /** - * {@inheritdoc} - */ - public function userMayTransition(ModerationStateInterface $from, ModerationStateInterface $to, AccountInterface $user) { - if ($transition = $this->getTransitionFromStates($from, $to)) { - return $user->hasPermission('use ' . $transition->id() . ' transition'); - } - return FALSE; - } - - /** - * Returns the transition object that transitions from one state to another. - * - * @param \Drupal\content_moderation\ModerationStateInterface $from - * The origin state. - * @param \Drupal\content_moderation\ModerationStateInterface $to - * The destination state. - * - * @return ModerationStateTransition|null - * A transition object, or NULL if there is no such transition. - */ - protected function getTransitionFromStates(ModerationStateInterface $from, ModerationStateInterface $to) { - $from = $this->transitionStateQuery() - ->condition('stateFrom', $from->id()) - ->condition('stateTo', $to->id()) - ->execute(); - - $transitions = $this->transitionStorage()->loadMultiple($from); - - if ($transitions) { - return current($transitions); - } - return NULL; - } - - /** - * {@inheritdoc} - */ - public function isTransitionAllowed(ModerationStateInterface $from, ModerationStateInterface $to) { - $allowed_transitions = $this->calculatePossibleTransitions(); - if (isset($allowed_transitions[$from->id()])) { - return in_array($to->id(), $allowed_transitions[$from->id()], TRUE); - } - return FALSE; - } - - /** - * Returns a transition state entity query. - * - * @return \Drupal\Core\Entity\Query\QueryInterface - * A transition state entity query. - */ - protected function transitionStateQuery() { - return $this->queryFactory->get('moderation_state_transition', 'AND'); - } - - /** - * Returns the transition entity storage service. - * - * @return \Drupal\Core\Entity\EntityStorageInterface - * The transition state entity storage. - */ - protected function transitionStorage() { - return $this->entityTypeManager->getStorage('moderation_state_transition'); - } - - /** - * Returns the state entity storage service. - * - * @return \Drupal\Core\Entity\EntityStorageInterface - * The moderation state entity storage. - */ - protected function stateStorage() { - return $this->entityTypeManager->getStorage('moderation_state'); - } - - /** - * Loads a specific bundle entity. - * - * @param string $bundle_entity_type_id - * The bundle entity type ID. - * @param string $bundle_id - * The bundle ID. - * - * @return \Drupal\Core\Config\Entity\ConfigEntityInterface|null - * The specific bundle entity. - */ - protected function loadBundleEntity($bundle_entity_type_id, $bundle_id) { - return $this->entityTypeManager->getStorage($bundle_entity_type_id)->load($bundle_id); } } diff --git a/core/modules/content_moderation/src/StateTransitionValidationInterface.php b/core/modules/content_moderation/src/StateTransitionValidationInterface.php index 5ef0dd1..6840b90 100644 --- a/core/modules/content_moderation/src/StateTransitionValidationInterface.php +++ b/core/modules/content_moderation/src/StateTransitionValidationInterface.php @@ -11,20 +11,6 @@ interface StateTransitionValidationInterface { /** - * Gets a list of states a user may transition an entity to. - * - * @param \Drupal\Core\Entity\ContentEntityInterface $entity - * The entity to be transitioned. - * @param \Drupal\Core\Session\AccountInterface $user - * The account that wants to perform a transition. - * - * @return \Drupal\content_moderation\Entity\ModerationState[] - * Returns an array of States to which the specified user may transition the - * entity. - */ - public function getValidTransitionTargets(ContentEntityInterface $entity, AccountInterface $user); - - /** * Gets a list of transitions that are legal for this user on this entity. * * @param \Drupal\Core\Entity\ContentEntityInterface $entity @@ -32,40 +18,9 @@ public function getValidTransitionTargets(ContentEntityInterface $entity, Accoun * @param \Drupal\Core\Session\AccountInterface $user * The account that wants to perform a transition. * - * @return \Drupal\content_moderation\Entity\ModerationStateTransition[] + * @return \Drupal\Core\Workflow\Transition[] * The list of transitions that are legal for this user on this entity. */ public function getValidTransitions(ContentEntityInterface $entity, AccountInterface $user); - /** - * Determines if a user is allowed to transition from one state to another. - * - * This method will also return FALSE if there is no transition between the - * specified states at all. - * - * @param \Drupal\content_moderation\ModerationStateInterface $from - * The origin state. - * @param \Drupal\content_moderation\ModerationStateInterface $to - * The destination state. - * @param \Drupal\Core\Session\AccountInterface $user - * The user to validate. - * - * @return bool - * TRUE if the given user may transition between those two states. - */ - public function userMayTransition(ModerationStateInterface $from, ModerationStateInterface $to, AccountInterface $user); - - /** - * Determines a transition allowed. - * - * @param \Drupal\content_moderation\ModerationStateInterface $from - * The origin state. - * @param \Drupal\content_moderation\ModerationStateInterface $to - * The destination state. - * - * @return bool - * Is the transition allowed. - */ - public function isTransitionAllowed(ModerationStateInterface $from, ModerationStateInterface $to); - } diff --git a/core/modules/content_moderation/src/Tests/ModerationFormTest.php b/core/modules/content_moderation/src/Tests/ModerationFormTest.php index d6c92b9..16da4e4 100644 --- a/core/modules/content_moderation/src/Tests/ModerationFormTest.php +++ b/core/modules/content_moderation/src/Tests/ModerationFormTest.php @@ -15,10 +15,7 @@ class ModerationFormTest extends ModerationStateTestBase { protected function setUp() { parent::setUp(); $this->drupalLogin($this->adminUser); - $this->createContentTypeFromUi('Moderated content', 'moderated_content', TRUE, [ - 'draft', - 'published', - ], 'draft'); + $this->createContentTypeFromUi('Moderated content', 'moderated_content', TRUE); $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content'); } diff --git a/core/modules/content_moderation/src/Tests/ModerationLocaleTest.php b/core/modules/content_moderation/src/Tests/ModerationLocaleTest.php index 0d40356..a78c104 100644 --- a/core/modules/content_moderation/src/Tests/ModerationLocaleTest.php +++ b/core/modules/content_moderation/src/Tests/ModerationLocaleTest.php @@ -28,13 +28,7 @@ public function testTranslateModeratedContent() { $this->drupalLogin($this->rootUser); // Enable moderation on Article node type. - $this->createContentTypeFromUi( - 'Article', - 'article', - TRUE, - ['draft', 'published', 'archived'], - 'draft' - ); + $this->createContentTypeFromUi('Article', 'article', TRUE); // Add French language. $edit = [ @@ -103,9 +97,9 @@ public function testTranslateModeratedContent() { $french_node = $english_node->getTranslation('fr'); $this->assertEqual('French node', $french_node->label()); - $this->assertEqual($english_node->moderation_state->target_id, 'published'); + $this->assertEqual($english_node->moderation_state->value, 'published'); $this->assertTrue($english_node->isPublished()); - $this->assertEqual($french_node->moderation_state->target_id, 'draft'); + $this->assertEqual($french_node->moderation_state->value, 'draft'); $this->assertFalse($french_node->isPublished()); // Create another article with its translation. This time we will publish @@ -133,9 +127,9 @@ public function testTranslateModeratedContent() { $this->assertText(t('Article Translated node has been updated.')); $english_node = $this->drupalGetNodeByTitle('Another node', TRUE); $french_node = $english_node->getTranslation('fr'); - $this->assertEqual($french_node->moderation_state->target_id, 'published'); + $this->assertEqual($french_node->moderation_state->value, 'published'); $this->assertTrue($french_node->isPublished()); - $this->assertEqual($english_node->moderation_state->target_id, 'draft'); + $this->assertEqual($english_node->moderation_state->value, 'draft'); $this->assertFalse($english_node->isPublished()); // Now check that we can create a new draft of the translation. @@ -146,7 +140,7 @@ public function testTranslateModeratedContent() { $this->assertText(t('Article New draft of translated node has been updated.')); $english_node = $this->drupalGetNodeByTitle('Another node', TRUE); $french_node = $english_node->getTranslation('fr'); - $this->assertEqual($french_node->moderation_state->target_id, 'published'); + $this->assertEqual($french_node->moderation_state->value, 'published'); $this->assertTrue($french_node->isPublished()); $this->assertEqual($french_node->getTitle(), 'Translated node', 'The default revision of the published translation remains the same.'); @@ -158,7 +152,7 @@ public function testTranslateModeratedContent() { $this->assertText(t('The moderation state has been updated.')); $english_node = $this->drupalGetNodeByTitle('Another node', TRUE); $french_node = $english_node->getTranslation('fr'); - $this->assertEqual($french_node->moderation_state->target_id, 'published'); + $this->assertEqual($french_node->moderation_state->value, 'published'); $this->assertTrue($french_node->isPublished()); $this->assertEqual($french_node->getTitle(), 'New draft of translated node', 'The draft has replaced the published revision.'); @@ -166,7 +160,7 @@ public function testTranslateModeratedContent() { $this->drupalPostForm('node/' . $english_node->id() . '/edit', [], t('Save and Publish (this translation)')); $this->assertText(t('Article Another node has been updated.')); $english_node = $this->drupalGetNodeByTitle('Another node', TRUE); - $this->assertEqual($english_node->moderation_state->target_id, 'published'); + $this->assertEqual($english_node->moderation_state->value, 'published'); // Archive the node and its translation. $this->drupalPostForm('node/' . $english_node->id() . '/edit', [], t('Save and Archive (this translation)')); @@ -175,9 +169,9 @@ public function testTranslateModeratedContent() { $this->assertText(t('Article New draft of translated node has been updated.')); $english_node = $this->drupalGetNodeByTitle('Another node', TRUE); $french_node = $english_node->getTranslation('fr'); - $this->assertEqual($english_node->moderation_state->target_id, 'archived'); + $this->assertEqual($english_node->moderation_state->value, 'archived'); $this->assertFalse($english_node->isPublished()); - $this->assertEqual($french_node->moderation_state->target_id, 'archived'); + $this->assertEqual($french_node->moderation_state->value, 'archived'); $this->assertFalse($french_node->isPublished()); // Create another article with its translation. This time publishing english diff --git a/core/modules/content_moderation/src/Tests/ModerationStateBlockTest.php b/core/modules/content_moderation/src/Tests/ModerationStateBlockTest.php index 03a4b0e..3a7a68c 100644 --- a/core/modules/content_moderation/src/Tests/ModerationStateBlockTest.php +++ b/core/modules/content_moderation/src/Tests/ModerationStateBlockTest.php @@ -59,12 +59,7 @@ public function testCustomBlockModeration() { // Enable moderation for custom blocks at // admin/structure/block/block-content/manage/basic/moderation. - $edit = [ - 'enable_moderation_state' => TRUE, - 'allowed_moderation_states_unpublished[draft]' => TRUE, - 'allowed_moderation_states_published[published]' => TRUE, - 'default_moderation_state' => 'draft', - ]; + $edit = ['workflow' => 'typical']; $this->drupalPostForm(NULL, $edit, t('Save')); $this->assertText(t('Your settings have been saved.')); diff --git a/core/modules/content_moderation/src/Tests/ModerationStateNodeTest.php b/core/modules/content_moderation/src/Tests/ModerationStateNodeTest.php index e2069b4..62adcc9 100644 --- a/core/modules/content_moderation/src/Tests/ModerationStateNodeTest.php +++ b/core/modules/content_moderation/src/Tests/ModerationStateNodeTest.php @@ -18,13 +18,7 @@ class ModerationStateNodeTest extends ModerationStateTestBase { protected function setUp() { parent::setUp(); $this->drupalLogin($this->adminUser); - $this->createContentTypeFromUi( - 'Moderated content', - 'moderated_content', - TRUE, - ['draft', 'needs_review', 'published'], - 'draft' - ); + $this->createContentTypeFromUi('Moderated content', 'moderated_content', TRUE); $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content'); } @@ -35,19 +29,11 @@ public function testCreatingContent() { $this->drupalPostForm('node/add/moderated_content', [ 'title[0][value]' => 'moderated content', ], t('Save and Create New Draft')); - $nodes = \Drupal::entityTypeManager() - ->getStorage('node') - ->loadByProperties([ - 'title' => 'moderated content', - ]); - - if (!$nodes) { + $node = $this->getNodeByTitle('moderated content'); + if (!$node) { $this->fail('Test node was not saved correctly.'); - return; } - - $node = reset($nodes); - $this->assertEqual('draft', $node->moderation_state->target_id); + $this->assertEqual('draft', $node->moderation_state->value); $path = 'node/' . $node->id() . '/edit'; // Set up published revision. @@ -56,7 +42,7 @@ public function testCreatingContent() { /* @var \Drupal\node\NodeInterface $node */ $node = \Drupal::entityTypeManager()->getStorage('node')->load($node->id()); $this->assertTrue($node->isPublished()); - $this->assertEqual('published', $node->moderation_state->target_id); + $this->assertEqual('published', $node->moderation_state->value); // Verify that the state field is not shown. $this->assertNoText('Published'); @@ -65,30 +51,26 @@ public function testCreatingContent() { $this->drupalPostForm('node/' . $node->id() . '/delete', array(), t('Delete')); $this->assertText(t('The Moderated content moderated content has been deleted.')); + // Disable content moderation. + $this->drupalPostForm('admin/structure/types/manage/moderated_content/moderation', ['workflow' => ''], t('Save')); $this->drupalGet('admin/structure/types/manage/moderated_content/moderation'); - $this->assertFieldByName('enable_moderation_state'); - $this->assertFieldChecked('edit-enable-moderation-state'); - $this->drupalPostForm(NULL, ['enable_moderation_state' => FALSE], t('Save')); - $this->drupalGet('admin/structure/types/manage/moderated_content/moderation'); - $this->assertFieldByName('enable_moderation_state'); - $this->assertNoFieldChecked('edit-enable-moderation-state'); + $this->assertOptionSelected('edit-workflow', ''); + // Clear bundle cache so workflow gets added or removed from the bundle + // information. + \Drupal::service('entity_type.bundle.info')->clearCachedBundles(); + // Clear field cache so extra field is added or removed. + \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); + + // Create a new node. $this->drupalPostForm('node/add/moderated_content', [ 'title[0][value]' => 'non-moderated content', ], t('Save and publish')); - $nodes = \Drupal::entityTypeManager() - ->getStorage('node') - ->loadByProperties([ - 'title' => 'non-moderated content', - ]); - - if (!$nodes) { + $node = $this->getNodeByTitle('non-moderated content'); + if (!$node) { $this->fail('Non-moderated test node was not saved correctly.'); - return; } - - $node = reset($nodes); - $this->assertEqual(NULL, $node->moderation_state->target_id); + $this->assertEqual(NULL, $node->moderation_state->value); } /** diff --git a/core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php b/core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php index debb32c..d23bdfe 100644 --- a/core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php +++ b/core/modules/content_moderation/src/Tests/ModerationStateNodeTypeTest.php @@ -42,12 +42,16 @@ public function testEnablingOnExistingContent() { ], t('Save and publish')); $this->assertText('Not moderated Test has been created.'); - // Now enable moderation state. - $this->enableModerationThroughUi( - 'not_moderated', - ['draft', 'needs_review', 'published'], - 'draft' - ); + // Now enable moderation state, ensuring all the expected links and tabs are + // present. + $this->drupalGet('admin/structure/types'); + $this->assertLinkByHref('admin/structure/types/manage/not_moderated/moderation'); + $this->drupalGet('admin/structure/types/manage/not_moderated'); + $this->assertLinkByHref('admin/structure/types/manage/not_moderated/moderation'); + $this->drupalGet('admin/structure/types/manage/not_moderated/moderation'); + $this->assertOptionSelected('edit-workflow', ''); + $edit['workflow'] = 'typical'; + $this->drupalPostForm(NULL, $edit, t('Save')); // And make sure it works. $nodes = \Drupal::entityTypeManager()->getStorage('node') diff --git a/core/modules/content_moderation/src/Tests/ModerationStateStatesTest.php b/core/modules/content_moderation/src/Tests/ModerationStateStatesTest.php deleted file mode 100644 index 3c5fd14..0000000 --- a/core/modules/content_moderation/src/Tests/ModerationStateStatesTest.php +++ /dev/null @@ -1,75 +0,0 @@ -drupalGet($path); - // No access. - $this->assertResponse(403); - } - $this->drupalLogin($this->adminUser); - foreach ($paths as $path) { - $this->drupalGet($path); - // User has access. - $this->assertResponse(200); - } - } - - /** - * Tests administration of moderation state entity. - */ - public function testStateAdministration() { - $this->drupalLogin($this->adminUser); - $this->drupalGet('admin/config/workflow/moderation'); - $this->assertLink('Moderation states'); - $this->assertLink('Moderation state transitions'); - $this->clickLink('Moderation states'); - $this->assertLink('Add moderation state'); - $this->assertText('Draft'); - // Edit the draft. - $this->clickLink('Edit', 0); - $this->assertFieldByName('label', 'Draft'); - $this->assertNoFieldChecked('edit-published'); - $this->drupalPostForm(NULL, [ - 'label' => 'Drafty', - ], t('Save')); - $this->assertText('Saved the Drafty Moderation state.'); - $this->drupalGet('admin/config/workflow/moderation/states/draft'); - $this->assertFieldByName('label', 'Drafty'); - $this->drupalPostForm(NULL, [ - 'label' => 'Draft', - ], t('Save')); - $this->assertText('Saved the Draft Moderation state.'); - $this->clickLink(t('Add moderation state')); - $this->drupalPostForm(NULL, [ - 'label' => 'Expired', - 'id' => 'expired', - ], t('Save')); - $this->assertText('Created the Expired Moderation state.'); - $this->drupalGet('admin/config/workflow/moderation/states/expired'); - $this->clickLink('Delete'); - $this->assertText('Are you sure you want to delete Expired?'); - $this->drupalPostForm(NULL, [], t('Delete')); - $this->assertText('Moderation state Expired deleted'); - } - -} diff --git a/core/modules/content_moderation/src/Tests/ModerationStateTestBase.php b/core/modules/content_moderation/src/Tests/ModerationStateTestBase.php index ddd275e..454dd08 100644 --- a/core/modules/content_moderation/src/Tests/ModerationStateTestBase.php +++ b/core/modules/content_moderation/src/Tests/ModerationStateTestBase.php @@ -5,7 +5,6 @@ use Drupal\Core\Session\AccountInterface; use Drupal\simpletest\WebTestBase; use Drupal\user\Entity\Role; -use Drupal\content_moderation\Entity\ModerationState; /** * Defines a base class for moderation state tests. @@ -30,12 +29,7 @@ * @var array */ protected $permissions = [ - 'administer moderation states', - 'administer moderation state transitions', - 'use draft_draft transition', - 'use draft_published transition', - 'use published_draft transition', - 'use published_archived transition', + 'administer content moderation', 'access administration pages', 'administer content types', 'administer nodes', @@ -61,6 +55,16 @@ */ protected function setUp() { parent::setUp(); + $transitions = [ + 'draft' => ['draft', 'published'], + 'published' => ['draft', 'published'], + ]; + $workflow_id = 'typical'; + foreach ($transitions as $from_state => $transition) { + foreach ($transition as $to_state) { + $this->permissions[] = $this->getWorkflowTransitionPermission($workflow_id, $from_state, $to_state); + } + } $this->adminUser = $this->drupalCreateUser($this->permissions); $this->drupalPlaceBlock('local_tasks_block', ['id' => 'tabs_block']); $this->drupalPlaceBlock('page_title_block'); @@ -68,6 +72,23 @@ protected function setUp() { } /** + * Gets the permission machine name for a transition. + * + * @param string $workflow_id + * The workflow ID. + * @param string $from_state + * The starting state. + * @param string $to_state + * The ending state. + * + * @return string + * The permission machine name for a transition. + */ + protected function getWorkflowTransitionPermission($workflow_id, $from_state, $to_state) { + return 'use ' . $workflow_id . ' transition from ' . $from_state . ' to ' . $to_state; + } + + /** * Creates a content-type from the UI. * * @param string $content_type_name @@ -76,12 +97,10 @@ protected function setUp() { * Machine name. * @param bool $moderated * TRUE if should be moderated. - * @param string[] $allowed_states - * Array of allowed state IDs. - * @param string $default_state - * Default state. + * @param string $workflow_id + * The workflow to attach to the bundle. */ - protected function createContentTypeFromUi($content_type_name, $content_type_id, $moderated = FALSE, array $allowed_states = [], $default_state = NULL) { + protected function createContentTypeFromUi($content_type_name, $content_type_id, $moderated = FALSE, $workflow_id = 'typical') { $this->drupalGet('admin/structure/types'); $this->clickLink('Add content type'); $edit = [ @@ -91,7 +110,7 @@ protected function createContentTypeFromUi($content_type_name, $content_type_id, $this->drupalPostForm(NULL, $edit, t('Save content type')); if ($moderated) { - $this->enableModerationThroughUi($content_type_id, $allowed_states, $default_state); + $this->enableModerationThroughUi($content_type_id, $workflow_id); } } @@ -100,31 +119,17 @@ protected function createContentTypeFromUi($content_type_name, $content_type_id, * * @param string $content_type_id * Machine name. - * @param string[] $allowed_states - * Array of allowed state IDs. - * @param string $default_state - * Default state. + * @param string $workflow_id + * The workflow to attach to the bundle. */ - protected function enableModerationThroughUi($content_type_id, array $allowed_states, $default_state) { - $this->drupalGet('admin/structure/types'); - $this->assertLinkByHref('admin/structure/types/manage/' . $content_type_id . '/moderation'); - $this->drupalGet('admin/structure/types/manage/' . $content_type_id); - $this->assertLinkByHref('admin/structure/types/manage/' . $content_type_id . '/moderation'); - $this->drupalGet('admin/structure/types/manage/' . $content_type_id . '/moderation'); - $this->assertFieldByName('enable_moderation_state'); - $this->assertNoFieldChecked('edit-enable-moderation-state'); - - $edit['enable_moderation_state'] = 1; - - /** @var ModerationState $state */ - foreach (ModerationState::loadMultiple() as $state) { - $key = $state->isPublishedState() ? 'allowed_moderation_states_published[' . $state->id() . ']' : 'allowed_moderation_states_unpublished[' . $state->id() . ']'; - $edit[$key] = in_array($state->id(), $allowed_states, TRUE) ? $state->id() : FALSE; - } - - $edit['default_moderation_state'] = $default_state; - - $this->drupalPostForm(NULL, $edit, t('Save')); + protected function enableModerationThroughUi($content_type_id, $workflow_id = 'typical') { + $edit['workflow'] = $workflow_id; + $this->drupalPostForm('admin/structure/types/manage/' . $content_type_id . '/moderation', $edit, t('Save')); + // Clear bundle cache so workflow gets added or removed from the bundle + // information. + \Drupal::service('entity_type.bundle.info')->clearCachedBundles(); + // Clear field cache so extra field is added or removed. + \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); } /** diff --git a/core/modules/content_moderation/src/Tests/ModerationStateTransitionsTest.php b/core/modules/content_moderation/src/Tests/ModerationStateTransitionsTest.php deleted file mode 100644 index 703561b..0000000 --- a/core/modules/content_moderation/src/Tests/ModerationStateTransitionsTest.php +++ /dev/null @@ -1,91 +0,0 @@ -drupalGet($path); - // No access. - $this->assertResponse(403); - } - $this->drupalLogin($this->adminUser); - foreach ($paths as $path) { - $this->drupalGet($path); - // User has access. - $this->assertResponse(200); - } - } - - /** - * Tests administration of moderation state transition entity. - */ - public function testTransitionAdministration() { - $this->drupalLogin($this->adminUser); - - $this->drupalGet('admin/config/workflow/moderation'); - $this->clickLink('Moderation state transitions'); - $this->assertLink('Add moderation state transition'); - $this->assertText('Create New Draft'); - - // Edit the Draft » Draft review. - $this->drupalGet('admin/config/workflow/moderation/transitions/draft_draft'); - $this->assertFieldByName('label', 'Create New Draft'); - $this->assertFieldByName('stateFrom', 'draft'); - $this->assertFieldByName('stateTo', 'draft'); - $this->drupalPostForm(NULL, [ - 'label' => 'Create Draft', - ], t('Save')); - $this->assertText('Saved the Create Draft Moderation state transition.'); - $this->drupalGet('admin/config/workflow/moderation/transitions/draft_draft'); - $this->assertFieldByName('label', 'Create Draft'); - // Now set it back. - $this->drupalPostForm(NULL, [ - 'label' => 'Create New Draft', - ], t('Save')); - $this->assertText('Saved the Create New Draft Moderation state transition.'); - - // Add a new state. - $this->drupalGet('admin/config/workflow/moderation/states/add'); - $this->drupalPostForm(NULL, [ - 'label' => 'Expired', - 'id' => 'expired', - ], t('Save')); - $this->assertText('Created the Expired Moderation state.'); - - // Add a new transition. - $this->drupalGet('admin/config/workflow/moderation/transitions'); - $this->clickLink(t('Add moderation state transition')); - $this->drupalPostForm(NULL, [ - 'label' => 'Published » Expired', - 'id' => 'published_expired', - 'stateFrom' => 'published', - 'stateTo' => 'expired', - ], t('Save')); - $this->assertText('Created the Published » Expired Moderation state transition.'); - - // Delete the new transition. - $this->drupalGet('admin/config/workflow/moderation/transitions/published_expired'); - $this->clickLink('Delete'); - $this->assertText('Are you sure you want to delete Published » Expired?'); - $this->drupalPostForm(NULL, [], t('Delete')); - $this->assertText('Moderation transition Published » Expired deleted'); - } - -} diff --git a/core/modules/content_moderation/src/Tests/NodeAccessTest.php b/core/modules/content_moderation/src/Tests/NodeAccessTest.php index 1b05406..b2f26d1 100644 --- a/core/modules/content_moderation/src/Tests/NodeAccessTest.php +++ b/core/modules/content_moderation/src/Tests/NodeAccessTest.php @@ -15,13 +15,7 @@ class NodeAccessTest extends ModerationStateTestBase { protected function setUp() { parent::setUp(); $this->drupalLogin($this->adminUser); - $this->createContentTypeFromUi( - 'Moderated content', - 'moderated_content', - TRUE, - ['draft', 'published'], - 'draft' - ); + $this->createContentTypeFromUi('Moderated content', 'moderated_content', TRUE); $this->grantUserPermissionToCreateContentOfType($this->adminUser, 'moderated_content'); } @@ -35,20 +29,11 @@ public function testPageAccess() { $this->drupalPostForm('node/add/moderated_content', [ 'title[0][value]' => 'moderated content', ], t('Save and Create New Draft')); - $nodes = \Drupal::entityTypeManager() - ->getStorage('node') - ->loadByProperties([ - 'title' => 'moderated content', - ]); - - if (!$nodes) { + $node = $this->getNodeByTitle('moderated content'); + if (!$node) { $this->fail('Test node was not saved correctly.'); - return; } - /** @var \Drupal\node\NodeInterface $node */ - $node = reset($nodes); - $view_path = 'node/' . $node->id(); $edit_path = 'node/' . $node->id() . '/edit'; $latest_path = 'node/' . $node->id() . '/latest'; @@ -75,8 +60,8 @@ public function testPageAccess() { // Now make a new user and verify that the new user's access is correct. $user = $this->createUser([ - 'use draft_draft transition', - 'use published_draft transition', + $this->getWorkflowTransitionPermission('typical', 'draft', 'draft'), + $this->getWorkflowTransitionPermission('typical', 'published', 'draft'), 'view latest version', 'view any unpublished content', ]); @@ -92,7 +77,7 @@ public function testPageAccess() { // Now make another user, who should not be able to see forward revisions. $user = $this->createUser([ - 'use published_draft transition', + $this->getWorkflowTransitionPermission('typical', 'published', 'draft'), ]); $this->drupalLogin($user); diff --git a/core/modules/content_moderation/src/ViewsData.php b/core/modules/content_moderation/src/ViewsData.php index 19bcfa5..b357abc 100644 --- a/core/modules/content_moderation/src/ViewsData.php +++ b/core/modules/content_moderation/src/ViewsData.php @@ -204,6 +204,7 @@ public function getViewsData() { ], ], ], + 'field' => ['default_formatter' => 'content_moderation_state'], ]; $revision_table = $entity_type->getRevisionDataTable() ?: $entity_type->getRevisionTable(); @@ -222,6 +223,7 @@ public function getViewsData() { ], ], ], + 'field' => ['default_formatter' => 'content_moderation_state'], ]; } diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml index 46a64ab..62e972e 100644 --- a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml +++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.latest.yml @@ -300,9 +300,7 @@ display: empty_zero: false hide_alter_empty: true click_sort_column: target_id - type: entity_reference_label - settings: - link: true + type: content_moderation_state group_column: target_id group_columns: { } group_rows: true diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_base_table_test.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_base_table_test.yml index 6f95251..343806f 100644 --- a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_base_table_test.yml +++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_base_table_test.yml @@ -193,9 +193,7 @@ display: empty_zero: false hide_alter_empty: true click_sort_column: target_id - type: entity_reference_label - settings: - link: false + type: content_moderation_state group_column: target_id group_columns: { } group_rows: true @@ -258,9 +256,7 @@ display: empty_zero: false hide_alter_empty: true click_sort_column: target_id - type: entity_reference_label - settings: - link: false + type: content_moderation_state group_column: target_id group_columns: { } group_rows: true @@ -323,8 +319,7 @@ display: empty_zero: false hide_alter_empty: true click_sort_column: target_id - type: entity_reference_entity_id - settings: { } + type: string group_column: target_id group_columns: { } group_rows: true diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_latest_revision.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_latest_revision.yml index 7673394..4727efa 100644 --- a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_latest_revision.yml +++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_latest_revision.yml @@ -306,7 +306,7 @@ display: empty_zero: false hide_alter_empty: true click_sort_column: target_id - type: entity_reference_entity_id + type: string settings: { } group_column: target_id group_columns: { } @@ -370,7 +370,7 @@ display: empty_zero: false hide_alter_empty: true click_sort_column: target_id - type: entity_reference_entity_id + type: string settings: { } group_column: target_id group_columns: { } diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_revision_test.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_revision_test.yml index 2362098..78fca38 100644 --- a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_revision_test.yml +++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_revision_test.yml @@ -191,7 +191,7 @@ display: empty_zero: false hide_alter_empty: true click_sort_column: target_id - type: entity_reference_entity_id + type: string settings: { } group_column: target_id group_columns: { } diff --git a/core/modules/content_moderation/tests/src/Functional/LatestRevisionViewsFilterTest.php b/core/modules/content_moderation/tests/src/Functional/LatestRevisionViewsFilterTest.php index 77ae046..316fb9d 100644 --- a/core/modules/content_moderation/tests/src/Functional/LatestRevisionViewsFilterTest.php +++ b/core/modules/content_moderation/tests/src/Functional/LatestRevisionViewsFilterTest.php @@ -5,6 +5,7 @@ use Drupal\node\Entity\Node; use Drupal\node\Entity\NodeType; use Drupal\Tests\BrowserTestBase; +use Drupal\Core\Workflow\Entity\Workflow; /** * Tests the "Latest Revision" views filter. @@ -25,7 +26,7 @@ class LatestRevisionViewsFilterTest extends BrowserTestBase { * Tests view shows the correct node IDs. */ public function testViewShowsCorrectNids() { - $node_type = $this->createNodeType('Test', 'test'); + $this->createNodeType('Test', 'test'); $permissions = [ 'access content', @@ -45,8 +46,14 @@ public function testViewShowsCorrectNids() { $node_0->save(); // Now enable moderation for subsequent nodes. - $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); - $node_type->save(); + $workflow = Workflow::load('typical'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'test'); + $workflow->save(); + // Clear bundle cache so workflow gets added or removed from the bundle + // information. + \Drupal::service('entity_type.bundle.info')->clearCachedBundles(); + // Clear field cache so extra field is added or removed. + \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); // Make a node that is only ever in Draft. /** @var Node $node_1 */ @@ -55,7 +62,7 @@ public function testViewShowsCorrectNids() { 'title' => 'Node 1 - Rev 1', 'uid' => $editor1->id(), ]); - $node_1->moderation_state->target_id = 'draft'; + $node_1->moderation_state->value = 'draft'; $node_1->save(); // Make a node that is in Draft, then Published. @@ -65,11 +72,11 @@ public function testViewShowsCorrectNids() { 'title' => 'Node 2 - Rev 1', 'uid' => $editor1->id(), ]); - $node_2->moderation_state->target_id = 'draft'; + $node_2->moderation_state->value = 'draft'; $node_2->save(); $node_2->setTitle('Node 2 - Rev 2'); - $node_2->moderation_state->target_id = 'published'; + $node_2->moderation_state->value = 'published'; $node_2->save(); // Make a node that is in Draft, then Published, then Draft. @@ -79,15 +86,15 @@ public function testViewShowsCorrectNids() { 'title' => 'Node 3 - Rev 1', 'uid' => $editor1->id(), ]); - $node_3->moderation_state->target_id = 'draft'; + $node_3->moderation_state->value = 'draft'; $node_3->save(); $node_3->setTitle('Node 3 - Rev 2'); - $node_3->moderation_state->target_id = 'published'; + $node_3->moderation_state->value = 'published'; $node_3->save(); $node_3->setTitle('Node 3 - Rev 3'); - $node_3->moderation_state->target_id = 'draft'; + $node_3->moderation_state->value = 'draft'; $node_3->save(); // Now show the View, and confirm that only the correct titles are showing. diff --git a/core/modules/content_moderation/tests/src/Functional/ModerationStateAccessTest.php b/core/modules/content_moderation/tests/src/Functional/ModerationStateAccessTest.php index 799d89a..8ff586e 100644 --- a/core/modules/content_moderation/tests/src/Functional/ModerationStateAccessTest.php +++ b/core/modules/content_moderation/tests/src/Functional/ModerationStateAccessTest.php @@ -5,6 +5,7 @@ use Drupal\node\Entity\Node; use Drupal\node\Entity\NodeType; use Drupal\Tests\BrowserTestBase; +use Drupal\Core\Workflow\Entity\Workflow; /** * Tests the view access control handler for moderation state entities. @@ -31,7 +32,7 @@ public function testViewShowsCorrectStates() { $permissions = [ 'access content', 'view all revisions', - 'view moderation states', + 'view content moderation', ]; $editor1 = $this->drupalCreateUser($permissions); $this->drupalLogin($editor1); @@ -41,7 +42,7 @@ public function testViewShowsCorrectStates() { 'title' => 'Draft node', 'uid' => $editor1->id(), ]); - $node_1->moderation_state->target_id = 'draft'; + $node_1->moderation_state->value = 'draft'; $node_1->save(); $node_2 = Node::create([ @@ -49,26 +50,26 @@ public function testViewShowsCorrectStates() { 'title' => 'Published node', 'uid' => $editor1->id(), ]); - $node_2->moderation_state->target_id = 'published'; + $node_2->moderation_state->value = 'published'; $node_2->save(); // Resave the node with a new state. $node_2->setTitle('Archived node'); - $node_2->moderation_state->target_id = 'archived'; + $node_2->moderation_state->value = 'archived'; $node_2->save(); // Now show the View, and confirm that the state labels are showing. $this->drupalGet('/latest'); $page = $this->getSession()->getPage(); - $this->assertTrue($page->hasLink('Draft')); - $this->assertTrue($page->hasLink('Archived')); - $this->assertFalse($page->hasLink('Published')); + $this->assertTrue($page->hasContent('Draft')); + $this->assertTrue($page->hasContent('Archived')); + $this->assertFalse($page->hasContent('Published')); // Now log in as an admin and test the same thing. $permissions = [ 'access content', 'view all revisions', - 'administer moderation states', + 'administer content moderation', ]; $admin1 = $this->drupalCreateUser($permissions); $this->drupalLogin($admin1); @@ -76,9 +77,9 @@ public function testViewShowsCorrectStates() { $this->drupalGet('/latest'); $page = $this->getSession()->getPage(); $this->assertEquals(200, $this->getSession()->getStatusCode()); - $this->assertTrue($page->hasLink('Draft')); - $this->assertTrue($page->hasLink('Archived')); - $this->assertFalse($page->hasLink('Published')); + $this->assertTrue($page->hasContent('Draft')); + $this->assertTrue($page->hasContent('Archived')); + $this->assertFalse($page->hasContent('Published')); } /** @@ -98,9 +99,16 @@ protected function createNodeType($label, $machine_name) { 'type' => $machine_name, 'label' => $label, ]); - $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); $node_type->save(); + $workflow = Workflow::load('typical'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', $machine_name); + $workflow->save(); + // Clear bundle cache so workflow gets added or removed from the bundle + // information. + \Drupal::service('entity_type.bundle.info')->clearCachedBundles(); + // Clear field cache so extra field is added or removed. + \Drupal::service('entity_field.manager')->clearCachedFieldDefinitions(); return $node_type; } diff --git a/core/modules/content_moderation/tests/src/Kernel/ContentModerationSchemaTest.php b/core/modules/content_moderation/tests/src/Kernel/ContentModerationSchemaTest.php deleted file mode 100644 index 8b382c1..0000000 --- a/core/modules/content_moderation/tests/src/Kernel/ContentModerationSchemaTest.php +++ /dev/null @@ -1,89 +0,0 @@ -installConfig(['content_moderation']); - $typed_config = \Drupal::service('config.typed'); - $moderation_states = ModerationState::loadMultiple(); - foreach ($moderation_states as $moderation_state) { - $this->assertConfigSchema($typed_config, $moderation_state->getEntityType()->getConfigPrefix() . '.' . $moderation_state->id(), $moderation_state->toArray()); - } - $moderation_state_transitions = ModerationStateTransition::loadMultiple(); - foreach ($moderation_state_transitions as $moderation_state_transition) { - $this->assertConfigSchema($typed_config, $moderation_state_transition->getEntityType()->getConfigPrefix() . '.' . $moderation_state_transition->id(), $moderation_state_transition->toArray()); - } - - } - - /** - * Tests content moderation third party schema for node types. - */ - public function testContentModerationNodeTypeConfig() { - $this->installEntitySchema('node'); - $this->installEntitySchema('user'); - $this->installConfig(['content_moderation']); - $typed_config = \Drupal::service('config.typed'); - $moderation_states = ModerationState::loadMultiple(); - $node_type = NodeType::create([ - 'type' => 'example', - ]); - $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); - $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($moderation_states)); - $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', ''); - $node_type->save(); - $this->assertConfigSchema($typed_config, $node_type->getEntityType()->getConfigPrefix() . '.' . $node_type->id(), $node_type->toArray()); - } - - /** - * Tests content moderation third party schema for block content types. - */ - public function testContentModerationBlockContentTypeConfig() { - $this->installEntitySchema('block_content'); - $this->installEntitySchema('user'); - $this->installConfig(['content_moderation']); - $typed_config = \Drupal::service('config.typed'); - $moderation_states = ModerationState::loadMultiple(); - $block_content_type = BlockContentType::create([ - 'id' => 'basic', - 'label' => 'basic', - 'revision' => TRUE, - ]); - $block_content_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); - $block_content_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', array_keys($moderation_states)); - $block_content_type->setThirdPartySetting('content_moderation', 'default_moderation_state', ''); - $block_content_type->save(); - $this->assertConfigSchema($typed_config, $block_content_type->getEntityType()->getConfigPrefix() . '.' . $block_content_type->id(), $block_content_type->toArray()); - } - -} diff --git a/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php b/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php index 67a7a74..56c5c8d 100644 --- a/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php +++ b/core/modules/content_moderation/tests/src/Kernel/ContentModerationStateTest.php @@ -3,7 +3,6 @@ namespace Drupal\Tests\content_moderation\Kernel; use Drupal\content_moderation\Entity\ContentModerationState; -use Drupal\content_moderation\Entity\ModerationState; use Drupal\entity_test\Entity\EntityTestBundle; use Drupal\entity_test\Entity\EntityTestWithBundle; use Drupal\KernelTests\KernelTestBase; @@ -11,6 +10,7 @@ use Drupal\node\Entity\Node; use Drupal\node\Entity\NodeType; use Drupal\node\NodeInterface; +use Drupal\Core\Workflow\Entity\Workflow; /** * Tests links between a content entity and a content_moderation_state entity. @@ -54,24 +54,25 @@ public function testBasicModeration() { $node_type = NodeType::create([ 'type' => 'example', ]); - $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); - $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', ['draft', 'published']); - $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', 'draft'); $node_type->save(); + + $workflow = Workflow::load('typical'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example'); + $workflow->save(); + $node = Node::create([ 'type' => 'example', 'title' => 'Test title', ]); $node->save(); $node = $this->reloadNode($node); - $this->assertEquals('draft', $node->moderation_state->entity->id()); + $this->assertEquals('draft', $node->moderation_state->value); - $published = ModerationState::load('published'); - $node->moderation_state->entity = $published; + $node->moderation_state->value = 'published'; $node->save(); $node = $this->reloadNode($node); - $this->assertEquals('published', $node->moderation_state->entity->id()); + $this->assertEquals('published', $node->moderation_state->value); // Change the state without saving the node. $content_moderation_state = ContentModerationState::load(1); @@ -80,7 +81,7 @@ public function testBasicModeration() { $content_moderation_state->save(); $node = $this->reloadNode($node, 3); - $this->assertEquals('draft', $node->moderation_state->entity->id()); + $this->assertEquals('draft', $node->moderation_state->value); $this->assertFalse($node->isPublished()); // Get the default revision. @@ -88,11 +89,11 @@ public function testBasicModeration() { $this->assertTrue($node->isPublished()); $this->assertEquals(2, $node->getRevisionId()); - $node->moderation_state->target_id = 'published'; + $node->moderation_state->value = 'published'; $node->save(); $node = $this->reloadNode($node, 4); - $this->assertEquals('published', $node->moderation_state->entity->id()); + $this->assertEquals('published', $node->moderation_state->value); // Get the default revision. $node = $this->reloadNode($node); @@ -110,10 +111,12 @@ public function testMultilingualModeration() { $node_type = NodeType::create([ 'type' => 'example', ]); - $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); - $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', ['draft', 'published']); - $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', 'draft'); $node_type->save(); + + $workflow = Workflow::load('typical'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example'); + $workflow->save(); + $english_node = Node::create([ 'type' => 'example', 'title' => 'Test title', @@ -122,7 +125,7 @@ public function testMultilingualModeration() { $english_node ->setPublished(FALSE) ->save(); - $this->assertEquals('draft', $english_node->moderation_state->entity->id()); + $this->assertEquals('draft', $english_node->moderation_state->value); $this->assertFalse($english_node->isPublished()); // Create a French translation. @@ -131,34 +134,34 @@ public function testMultilingualModeration() { // Revision 1 (fr). $french_node->save(); $french_node = $this->reloadNode($english_node)->getTranslation('fr'); - $this->assertEquals('draft', $french_node->moderation_state->entity->id()); + $this->assertEquals('draft', $french_node->moderation_state->value); $this->assertFalse($french_node->isPublished()); // Move English node to create another draft. $english_node = $this->reloadNode($english_node); - $english_node->moderation_state->target_id = 'draft'; + $english_node->moderation_state->value = 'draft'; // Revision 2 (en, fr). $english_node->save(); $english_node = $this->reloadNode($english_node); - $this->assertEquals('draft', $english_node->moderation_state->entity->id()); + $this->assertEquals('draft', $english_node->moderation_state->value); // French node should still be in draft. $french_node = $this->reloadNode($english_node)->getTranslation('fr'); - $this->assertEquals('draft', $french_node->moderation_state->entity->id()); + $this->assertEquals('draft', $french_node->moderation_state->value); // Publish the French node. - $french_node->moderation_state->target_id = 'published'; + $french_node->moderation_state->value = 'published'; // Revision 3 (en, fr). $french_node->save(); $french_node = $this->reloadNode($french_node)->getTranslation('fr'); $this->assertTrue($french_node->isPublished()); - $this->assertEquals('published', $french_node->moderation_state->entity->id()); + $this->assertEquals('published', $french_node->moderation_state->value); $this->assertTrue($french_node->isPublished()); $english_node = $french_node->getTranslation('en'); - $this->assertEquals('draft', $english_node->moderation_state->entity->id()); + $this->assertEquals('draft', $english_node->moderation_state->value); // Publish the English node. - $english_node->moderation_state->target_id = 'published'; + $english_node->moderation_state->value = 'published'; // Revision 4 (en, fr). $english_node->save(); $english_node = $this->reloadNode($english_node); @@ -167,7 +170,7 @@ public function testMultilingualModeration() { // Move the French node back to draft. $french_node = $this->reloadNode($english_node)->getTranslation('fr'); $this->assertTrue($french_node->isPublished()); - $french_node->moderation_state->target_id = 'draft'; + $french_node->moderation_state->value = 'draft'; // Revision 5 (en, fr). $french_node->save(); $french_node = $this->reloadNode($english_node, 5)->getTranslation('fr'); @@ -175,7 +178,7 @@ public function testMultilingualModeration() { $this->assertTrue($french_node->getTranslation('en')->isPublished()); // Republish the French node. - $french_node->moderation_state->target_id = 'published'; + $french_node->moderation_state->value = 'published'; // Revision 6 (en, fr). $french_node->save(); $french_node = $this->reloadNode($english_node)->getTranslation('fr'); @@ -189,9 +192,9 @@ public function testMultilingualModeration() { $content_moderation_state->save(); $english_node = $this->reloadNode($french_node, $french_node->getRevisionId() + 1); - $this->assertEquals('draft', $english_node->moderation_state->entity->id()); + $this->assertEquals('draft', $english_node->moderation_state->value); $french_node = $this->reloadNode($english_node)->getTranslation('fr'); - $this->assertEquals('published', $french_node->moderation_state->entity->id()); + $this->assertEquals('published', $french_node->moderation_state->value); // This should unpublish the French node. $content_moderation_state = ContentModerationState::load(1); @@ -202,9 +205,9 @@ public function testMultilingualModeration() { $content_moderation_state->save(); $english_node = $this->reloadNode($english_node, $english_node->getRevisionId()); - $this->assertEquals('draft', $english_node->moderation_state->entity->id()); + $this->assertEquals('draft', $english_node->moderation_state->value); $french_node = $this->reloadNode($english_node, '8')->getTranslation('fr'); - $this->assertEquals('draft', $french_node->moderation_state->entity->id()); + $this->assertEquals('draft', $french_node->moderation_state->value); // Switching the moderation state to an unpublished state should update the // entity. $this->assertFalse($french_node->isPublished()); @@ -231,14 +234,12 @@ public function testNonTranslatableEntityTypeModeration() { $entity_test_bundle = EntityTestBundle::create([ 'id' => 'example', ]); - $entity_test_bundle->setThirdPartySetting('content_moderation', 'enabled', TRUE); - $entity_test_bundle->setThirdPartySetting('content_moderation', 'allowed_moderation_states', [ - 'draft', - 'published' - ]); - $entity_test_bundle->setThirdPartySetting('content_moderation', 'default_moderation_state', 'draft'); $entity_test_bundle->save(); + $workflow = Workflow::load('typical'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test_with_bundle', 'example'); + $workflow->save(); + // Check that the tested entity type is not translatable. $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_with_bundle'); $this->assertFalse($entity_type->isTranslatable(), 'The test entity type is not translatable.'); @@ -248,12 +249,12 @@ public function testNonTranslatableEntityTypeModeration() { 'type' => 'example' ]); $entity_test_with_bundle->save(); - $this->assertEquals('draft', $entity_test_with_bundle->moderation_state->entity->id()); + $this->assertEquals('draft', $entity_test_with_bundle->moderation_state->value); - $entity_test_with_bundle->moderation_state->target_id = 'published'; + $entity_test_with_bundle->moderation_state->value = 'published'; $entity_test_with_bundle->save(); - $this->assertEquals('published', EntityTestWithBundle::load($entity_test_with_bundle->id())->moderation_state->entity->id()); + $this->assertEquals('published', EntityTestWithBundle::load($entity_test_with_bundle->id())->moderation_state->value); } /** @@ -275,14 +276,12 @@ public function testNonLangcodeEntityTypeModeration() { $entity_test_bundle = EntityTestBundle::create([ 'id' => 'example', ]); - $entity_test_bundle->setThirdPartySetting('content_moderation', 'enabled', TRUE); - $entity_test_bundle->setThirdPartySetting('content_moderation', 'allowed_moderation_states', [ - 'draft', - 'published' - ]); - $entity_test_bundle->setThirdPartySetting('content_moderation', 'default_moderation_state', 'draft'); $entity_test_bundle->save(); + $workflow = Workflow::load('typical'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test_with_bundle', 'example'); + $workflow->save(); + // Check that the tested entity type is not translatable. $entity_type = \Drupal::entityTypeManager()->getDefinition('entity_test_with_bundle'); $this->assertFalse($entity_type->isTranslatable(), 'The test entity type is not translatable.'); @@ -292,12 +291,12 @@ public function testNonLangcodeEntityTypeModeration() { 'type' => 'example' ]); $entity_test_with_bundle->save(); - $this->assertEquals('draft', $entity_test_with_bundle->moderation_state->entity->id()); + $this->assertEquals('draft', $entity_test_with_bundle->moderation_state->value); - $entity_test_with_bundle->moderation_state->target_id = 'published'; + $entity_test_with_bundle->moderation_state->value = 'published'; $entity_test_with_bundle->save(); - $this->assertEquals('published', EntityTestWithBundle::load($entity_test_with_bundle->id())->moderation_state->entity->id()); + $this->assertEquals('published', EntityTestWithBundle::load($entity_test_with_bundle->id())->moderation_state->value); } /** diff --git a/core/modules/content_moderation/tests/src/Kernel/EntityOperationsTest.php b/core/modules/content_moderation/tests/src/Kernel/EntityOperationsTest.php index 929356e..98c151d 100644 --- a/core/modules/content_moderation/tests/src/Kernel/EntityOperationsTest.php +++ b/core/modules/content_moderation/tests/src/Kernel/EntityOperationsTest.php @@ -4,9 +4,9 @@ use Drupal\KernelTests\KernelTestBase; -use Drupal\content_moderation\Entity\ModerationState; use Drupal\node\Entity\Node; use Drupal\node\Entity\NodeType; +use Drupal\Core\Workflow\Entity\Workflow; /** * @coversDefaultClass \Drupal\content_moderation\EntityOperations @@ -47,8 +47,10 @@ protected function createNodeType() { 'type' => 'page', 'label' => 'Page', ]); - $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); $node_type->save(); + $workflow = Workflow::load('typical'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'page'); + $workflow->save(); } /** @@ -60,7 +62,7 @@ public function testForwardRevisions() { 'type' => 'page', 'title' => 'A', ]); - $page->moderation_state->target_id = 'draft'; + $page->moderation_state->value = 'draft'; $page->save(); $id = $page->id(); @@ -75,7 +77,7 @@ public function testForwardRevisions() { // Moderate the entity to published. $page->setTitle('B'); - $page->moderation_state->target_id = 'published'; + $page->moderation_state->value = 'published'; $page->save(); // Verify the entity is now published and public. @@ -86,7 +88,7 @@ public function testForwardRevisions() { // Make a new forward-revision in Draft. $page->setTitle('C'); - $page->moderation_state->target_id = 'draft'; + $page->moderation_state->value = 'draft'; $page->save(); // Verify normal loads return the still-default previous version. @@ -105,7 +107,7 @@ public function testForwardRevisions() { $this->assertEquals('C', $page->getTitle()); $page->setTitle('D'); - $page->moderation_state->target_id = 'published'; + $page->moderation_state->value = 'published'; $page->save(); // Verify normal loads return the still-default previous version. @@ -116,7 +118,7 @@ public function testForwardRevisions() { // Now check that we can immediately add a new published revision over it. $page->setTitle('E'); - $page->moderation_state->target_id = 'published'; + $page->moderation_state->value = 'published'; $page->save(); $page = Node::load($id); @@ -134,7 +136,7 @@ public function testPublishedCreation() { 'type' => 'page', 'title' => 'A', ]); - $page->moderation_state->target_id = 'published'; + $page->moderation_state->value = 'published'; $page->save(); $id = $page->id(); @@ -151,29 +153,12 @@ public function testPublishedCreation() { * Verifies that an unpublished state may be made the default revision. */ public function testArchive() { - $published_id = $this->randomMachineName(); - $published_state = ModerationState::create([ - 'id' => $published_id, - 'label' => $this->randomString(), - 'published' => TRUE, - 'default_revision' => TRUE, - ]); - $published_state->save(); - - $archived_id = $this->randomMachineName(); - $archived_state = ModerationState::create([ - 'id' => $archived_id, - 'label' => $this->randomString(), - 'published' => FALSE, - 'default_revision' => TRUE, - ]); - $archived_state->save(); - $page = Node::create([ 'type' => 'page', 'title' => $this->randomString(), ]); - $page->moderation_state->target_id = $published_id; + + $page->moderation_state->value = 'published'; $page->save(); $id = $page->id(); @@ -184,7 +169,7 @@ public function testArchive() { // When the page is moderated to the archived state, then the latest // revision should be the default revision, and it should be unpublished. - $page->moderation_state->target_id = $archived_id; + $page->moderation_state->value = 'archived'; $page->save(); $new_revision_id = $page->getRevisionId(); diff --git a/core/modules/content_moderation/tests/src/Kernel/EntityRevisionConverterTest.php b/core/modules/content_moderation/tests/src/Kernel/EntityRevisionConverterTest.php index 89c84f9..2181147 100644 --- a/core/modules/content_moderation/tests/src/Kernel/EntityRevisionConverterTest.php +++ b/core/modules/content_moderation/tests/src/Kernel/EntityRevisionConverterTest.php @@ -6,6 +6,7 @@ use Drupal\KernelTests\KernelTestBase; use Drupal\node\Entity\Node; use Drupal\node\Entity\NodeType; +use Drupal\Core\Workflow\Entity\Workflow; /** * @coversDefaultClass \Drupal\content_moderation\ParamConverter\EntityRevisionConverter @@ -59,17 +60,21 @@ public function testConvertNonRevisionableEntityType() { * @covers ::convert */ public function testConvertWithRevisionableEntityType() { + $this->installConfig(['content_moderation']); $node_type = NodeType::create([ 'type' => 'article', ]); - $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); $node_type->save(); + $workflow = Workflow::load('typical'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'article'); + $workflow->save(); $revision_ids = []; $node = Node::create([ 'title' => 'test', 'type' => 'article', ]); + $node->moderation_state->value = 'published'; $node->save(); $revision_ids[] = $node->getRevisionId(); @@ -79,7 +84,7 @@ public function testConvertWithRevisionableEntityType() { $revision_ids[] = $node->getRevisionId(); $node->setNewRevision(TRUE); - $node->isDefaultRevision(FALSE); + $node->moderation_state->value = 'draft'; $node->save(); $revision_ids[] = $node->getRevisionId(); diff --git a/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php b/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php index 97e61f1..0b4a136 100644 --- a/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php +++ b/core/modules/content_moderation/tests/src/Kernel/EntityStateChangeValidationTest.php @@ -6,6 +6,7 @@ use Drupal\language\Entity\ConfigurableLanguage; use Drupal\node\Entity\Node; use Drupal\node\Entity\NodeType; +use Drupal\Core\Workflow\Entity\Workflow; /** * @coversDefaultClass \Drupal\content_moderation\Plugin\Validation\Constraint\ModerationStateConstraintValidator @@ -47,20 +48,23 @@ public function testValidTransition() { $node_type = NodeType::create([ 'type' => 'example', ]); - $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); $node_type->save(); + $workflow = Workflow::load('typical'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example'); + $workflow->save(); + $node = Node::create([ 'type' => 'example', 'title' => 'Test title', ]); - $node->moderation_state->target_id = 'draft'; + $node->moderation_state->value = 'draft'; $node->save(); - $node->moderation_state->target_id = 'published'; + $node->moderation_state->value = 'published'; $this->assertCount(0, $node->validate()); $node->save(); - $this->assertEquals('published', $node->moderation_state->entity->id()); + $this->assertEquals('published', $node->moderation_state->value); } /** @@ -72,16 +76,19 @@ public function testInvalidTransition() { $node_type = NodeType::create([ 'type' => 'example', ]); - $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); $node_type->save(); + $workflow = Workflow::load('typical'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example'); + $workflow->save(); + $node = Node::create([ 'type' => 'example', 'title' => 'Test title', ]); - $node->moderation_state->target_id = 'draft'; + $node->moderation_state->value = 'draft'; $node->save(); - $node->moderation_state->target_id = 'archived'; + $node->moderation_state->value = 'archived'; $violations = $node->validate(); $this->assertCount(1, $violations); @@ -106,12 +113,9 @@ public function testLegacyContent() { $nid = $node->id(); // Enable moderation for our node type. - /** @var NodeType $node_type */ - $node_type = NodeType::load('example'); - $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); - $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', ['draft', 'published']); - $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', 'draft'); - $node_type->save(); + $workflow = Workflow::load('typical'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example'); + $workflow->save(); $node = Node::load($nid); @@ -155,12 +159,9 @@ public function testLegacyMultilingualContent() { $node_fr->save(); // Enable moderation for our node type. - /** @var NodeType $node_type */ - $node_type = NodeType::load('example'); - $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); - $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', ['draft', 'published']); - $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', 'draft'); - $node_type->save(); + $workflow = Workflow::load('typical'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example'); + $workflow->save(); // Reload the French version of the node. $node = Node::load($nid); diff --git a/core/modules/content_moderation/tests/src/Kernel/ModerationStateEntityTest.php b/core/modules/content_moderation/tests/src/Kernel/ModerationStateEntityTest.php deleted file mode 100644 index f312cde..0000000 --- a/core/modules/content_moderation/tests/src/Kernel/ModerationStateEntityTest.php +++ /dev/null @@ -1,69 +0,0 @@ -installEntitySchema('moderation_state'); - } - - /** - * Verify moderation state methods based on entity properties. - * - * @covers ::isPublishedState - * @covers ::isDefaultRevisionState - * - * @dataProvider moderationStateProvider - */ - public function testModerationStateProperties($published, $default_revision, $is_published, $is_default) { - $moderation_state_id = $this->randomMachineName(); - $moderation_state = ModerationState::create([ - 'id' => $moderation_state_id, - 'label' => $this->randomString(), - 'published' => $published, - 'default_revision' => $default_revision, - ]); - $moderation_state->save(); - - $moderation_state = ModerationState::load($moderation_state_id); - $this->assertEquals($is_published, $moderation_state->isPublishedState()); - $this->assertEquals($is_default, $moderation_state->isDefaultRevisionState()); - } - - /** - * Data provider for ::testModerationStateProperties. - */ - public function moderationStateProvider() { - return [ - // Draft, Needs review; should not touch the default revision. - [FALSE, FALSE, FALSE, FALSE], - // Published; this state should update and publish the default revision. - [TRUE, TRUE, TRUE, TRUE], - // Archive; this state should update but not publish the default revision. - [FALSE, TRUE, FALSE, TRUE], - // We try to prevent creating this state via the UI, but when a moderation - // state is a published state, it should also become the default revision. - [TRUE, FALSE, TRUE, TRUE], - ]; - } - -} diff --git a/core/modules/content_moderation/tests/src/Kernel/ModerationStateFieldItemListTest.php b/core/modules/content_moderation/tests/src/Kernel/ModerationStateFieldItemListTest.php index c57963e..b186668 100644 --- a/core/modules/content_moderation/tests/src/Kernel/ModerationStateFieldItemListTest.php +++ b/core/modules/content_moderation/tests/src/Kernel/ModerationStateFieldItemListTest.php @@ -5,6 +5,7 @@ use Drupal\KernelTests\KernelTestBase; use Drupal\node\Entity\Node; use Drupal\node\Entity\NodeType; +use Drupal\Core\Workflow\Entity\Workflow; /** * @coversDefaultClass \Drupal\content_moderation\Plugin\Field\ModerationStateFieldItemList @@ -44,10 +45,11 @@ protected function setUp() { $node_type = NodeType::create([ 'type' => 'example', ]); - $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); - $node_type->setThirdPartySetting('content_moderation', 'allowed_moderation_states', ['draft']); - $node_type->setThirdPartySetting('content_moderation', 'default_moderation_state', 'draft'); $node_type->save(); + $workflow = Workflow::load('typical'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example'); + $workflow->save(); + $this->testNode = Node::create([ 'type' => 'example', 'title' => 'Test title', @@ -61,7 +63,7 @@ protected function setUp() { * Test the field item list when accessing an index. */ public function testArrayIndex() { - $this->assertEquals('draft', $this->testNode->moderation_state[0]->entity->id()); + $this->assertEquals('draft', $this->testNode->moderation_state[0]->value); } /** @@ -70,7 +72,7 @@ public function testArrayIndex() { public function testArrayIteration() { $states = []; foreach ($this->testNode->moderation_state as $item) { - $states[] = $item->entity->id(); + $states[] = $item->value; } $this->assertEquals(['draft'], $states); } diff --git a/core/modules/content_moderation/tests/src/Kernel/ViewsDataIntegrationTest.php b/core/modules/content_moderation/tests/src/Kernel/ViewsDataIntegrationTest.php index c869619..ada557f 100644 --- a/core/modules/content_moderation/tests/src/Kernel/ViewsDataIntegrationTest.php +++ b/core/modules/content_moderation/tests/src/Kernel/ViewsDataIntegrationTest.php @@ -6,6 +6,7 @@ use Drupal\node\Entity\NodeType; use Drupal\Tests\views\Kernel\ViewsKernelTestBase; use Drupal\views\Views; +use Drupal\Core\Workflow\Entity\Workflow; /** * Tests the views integration of content_moderation. @@ -39,8 +40,10 @@ protected function setUp($import_test_views = TRUE) { $node_type = NodeType::create([ 'type' => 'page', ]); - $node_type->setThirdPartySetting('content_moderation', 'enabled', TRUE); $node_type->save(); + $workflow = Workflow::load('typical'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'page'); + $workflow->save(); } /** @@ -53,14 +56,14 @@ public function testViewsData() { 'type' => 'page', 'title' => 'Test title first revision', ]); - $node->moderation_state->target_id = 'published'; + $node->moderation_state->value = 'published'; $node->save(); $revision = clone $node; $revision->setNewRevision(TRUE); $revision->isDefaultRevision(FALSE); $revision->title->value = 'Test title second revision'; - $revision->moderation_state->target_id = 'draft'; + $revision->moderation_state->value = 'draft'; $revision->save(); $view = Views::getView('test_content_moderation_latest_revision'); @@ -90,14 +93,14 @@ public function testContentModerationStateRevisionJoin() { 'type' => 'page', 'title' => 'Test title first revision', ]); - $node->moderation_state->target_id = 'published'; + $node->moderation_state->value = 'published'; $node->save(); $revision = clone $node; $revision->setNewRevision(TRUE); $revision->isDefaultRevision(FALSE); $revision->title->value = 'Test title second revision'; - $revision->moderation_state->target_id = 'draft'; + $revision->moderation_state->value = 'draft'; $revision->save(); $view = Views::getView('test_content_moderation_revision_test'); @@ -124,14 +127,14 @@ public function testContentModerationStateBaseJoin() { 'type' => 'page', 'title' => 'Test title first revision', ]); - $node->moderation_state->target_id = 'published'; + $node->moderation_state->value = 'published'; $node->save(); $revision = clone $node; $revision->setNewRevision(TRUE); $revision->isDefaultRevision(FALSE); $revision->title->value = 'Test title second revision'; - $revision->moderation_state->target_id = 'draft'; + $revision->moderation_state->value = 'draft'; $revision->save(); $view = Views::getView('test_content_moderation_base_table_test'); diff --git a/core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php b/core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php index ff46e41..36c1e39 100644 --- a/core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php +++ b/core/modules/content_moderation/tests/src/Unit/ModerationInformationTest.php @@ -3,13 +3,14 @@ namespace Drupal\Tests\content_moderation\Unit; use Drupal\content_moderation\Entity\Handler\ModerationHandler; -use Drupal\Core\Config\Entity\ConfigEntityInterface; use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\ContentEntityType; use Drupal\Core\Entity\EntityStorageInterface; +use Drupal\Core\Entity\EntityTypeBundleInfoInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Session\AccountInterface; use Drupal\content_moderation\ModerationInformation; +use Drupal\Core\Workflow\WorkflowInterface; /** * @coversDefaultClass \Drupal\content_moderation\ModerationInformation @@ -30,43 +31,42 @@ protected function getUser() { /** * Returns a mock Entity Type Manager. * - * @param \Drupal\Core\Entity\EntityStorageInterface $entity_bundle_storage - * Entity bundle storage. - * * @return EntityTypeManagerInterface * The mocked entity type manager. */ - protected function getEntityTypeManager(EntityStorageInterface $entity_bundle_storage) { + protected function getEntityTypeManager() { $entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class); - $entity_type_manager->getStorage('entity_test_bundle')->willReturn($entity_bundle_storage); return $entity_type_manager->reveal(); } /** * Sets up content moderation and entity manager mocking. * - * @param bool $status - * TRUE if content_moderation should be enabled, FALSE if not. + * @param string $bundle + * The bundle ID. + * @param string|null $workflow + * The workflow ID. If nul no workflow information is added to the bundle. * * @return \Drupal\Core\Entity\EntityTypeManagerInterface * The mocked entity type manager. */ - public function setupModerationEntityManager($status) { - $bundle = $this->prophesize(ConfigEntityInterface::class); - $bundle->getThirdPartySetting('content_moderation', 'enabled', FALSE)->willReturn($status); - - $entity_storage = $this->prophesize(EntityStorageInterface::class); - $entity_storage->load('test_bundle')->willReturn($bundle->reveal()); - - return $this->getEntityTypeManager($entity_storage->reveal()); + public function setupModerationBundleInfo($bundle, $workflow = NULL) { + $bundle_info_array = []; + if ($workflow) { + $bundle_info_array['workflow'] = $workflow; + } + $bundle_info = $this->prophesize(EntityTypeBundleInfoInterface::class); + $bundle_info->getBundleInfo("test_entity_type")->willReturn([$bundle => $bundle_info_array]); + + return $bundle_info->reveal(); } /** - * @dataProvider providerBoolean + * @dataProvider providerWorkflow * @covers ::isModeratedEntity */ - public function testIsModeratedEntity($status) { - $moderation_information = new ModerationInformation($this->setupModerationEntityManager($status), $this->getUser()); + public function testIsModeratedEntity($workflow, $expected) { + $moderation_information = new ModerationInformation($this->getEntityTypeManager(), $this->setupModerationBundleInfo('test_bundle', $workflow)); $entity_type = new ContentEntityType([ 'id' => 'test_entity_type', @@ -77,50 +77,55 @@ public function testIsModeratedEntity($status) { $entity->getEntityType()->willReturn($entity_type); $entity->bundle()->willReturn('test_bundle'); - $this->assertEquals($status, $moderation_information->isModeratedEntity($entity->reveal())); + $this->assertEquals($expected, $moderation_information->isModeratedEntity($entity->reveal())); } /** - * @covers ::isModeratedEntity + * @dataProvider providerWorkflow + * @covers ::getWorkFlowForEntity */ - public function testIsModeratedEntityForNonBundleEntityType() { - $entity_type = new ContentEntityType([ - 'id' => 'test_entity_type', - ]); + public function testGetWorkFlowForEntity($workflow) { + $entity_type_manager = $this->prophesize(EntityTypeManagerInterface::class); + if ($workflow) { + $workflow_entity = $this->prophesize(WorkflowInterface::class)->reveal(); + $workflow_storage = $this->prophesize(EntityStorageInterface::class); + $workflow_storage->load('workflow')->willReturn($workflow_entity)->shouldBeCalled(); + $entity_type_manager->getStorage('workflow')->willReturn($workflow_storage->reveal()); + } + else { + $workflow_entity = NULL; + } + $moderation_information = new ModerationInformation($entity_type_manager->reveal(), $this->setupModerationBundleInfo('test_bundle', $workflow)); $entity = $this->prophesize(ContentEntityInterface::class); - $entity->getEntityType()->willReturn($entity_type); - $entity->bundle()->willReturn('test_entity_type'); - - $entity_storage = $this->prophesize(EntityStorageInterface::class); - $entity_type_manager = $this->getEntityTypeManager($entity_storage->reveal()); - $moderation_information = new ModerationInformation($entity_type_manager, $this->getUser()); + $entity->getEntityTypeId()->willReturn('test_entity_type'); + $entity->bundle()->willReturn('test_bundle'); - $this->assertEquals(FALSE, $moderation_information->isModeratedEntity($entity->reveal())); + $this->assertEquals($workflow_entity, $moderation_information->getWorkFlowForEntity($entity->reveal())); } /** - * @dataProvider providerBoolean + * @dataProvider providerWorkflow * @covers ::shouldModerateEntitiesOfBundle */ - public function testShouldModerateEntities($status) { + public function testShouldModerateEntities($workflow, $expected) { $entity_type = new ContentEntityType([ 'id' => 'test_entity_type', 'bundle_entity_type' => 'entity_test_bundle', 'handlers' => ['moderation' => ModerationHandler::class], ]); - $moderation_information = new ModerationInformation($this->setupModerationEntityManager($status), $this->getUser()); + $moderation_information = new ModerationInformation($this->getEntityTypeManager(), $this->setupModerationBundleInfo('test_bundle', $workflow)); - $this->assertEquals($status, $moderation_information->shouldModerateEntitiesOfBundle($entity_type, 'test_bundle')); + $this->assertEquals($expected, $moderation_information->shouldModerateEntitiesOfBundle($entity_type, 'test_bundle')); } /** * Data provider for several tests. */ - public function providerBoolean() { + public function providerWorkflow() { return [ - [FALSE], - [TRUE], + [NULL, FALSE], + ['workflow', TRUE], ]; } diff --git a/core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php b/core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php index b057478..72e03a0 100644 --- a/core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php +++ b/core/modules/content_moderation/tests/src/Unit/StateTransitionValidationTest.php @@ -2,13 +2,14 @@ namespace Drupal\Tests\content_moderation\Unit; -use Drupal\Core\Entity\EntityStorageInterface; -use Drupal\Core\Entity\EntityTypeManagerInterface; -use Drupal\Core\Entity\Query\QueryFactory; +use Drupal\content_moderation\ModerationInformationInterface; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Session\AccountInterface; -use Drupal\content_moderation\ModerationStateInterface; -use Drupal\content_moderation\ModerationStateTransitionInterface; use Drupal\content_moderation\StateTransitionValidation; +use Drupal\Core\Workflow\Entity\Workflow; +use Drupal\Core\Workflow\WorkflowTypeInterface; +use Drupal\Core\Workflow\WorkflowTypeManager; use Prophecy\Argument; /** @@ -18,216 +19,6 @@ class StateTransitionValidationTest extends \PHPUnit_Framework_TestCase { /** - * Builds a mock storage object for Transitions. - * - * @return EntityStorageInterface - * The mocked storage object for Transitions. - */ - protected function setupTransitionStorage() { - $entity_storage = $this->prophesize(EntityStorageInterface::class); - - $list = $this->setupTransitionEntityList(); - $entity_storage->loadMultiple()->willReturn($list); - $entity_storage->loadMultiple(Argument::type('array'))->will(function ($args) use ($list) { - $keys = $args[0]; - if (empty($keys)) { - return $list; - } - - $return = array_map(function($key) use ($list) { - return $list[$key]; - }, $keys); - - return $return; - }); - return $entity_storage->reveal(); - } - - /** - * Builds an array of mocked Transition objects. - * - * @return ModerationStateTransitionInterface[] - * An array of mocked Transition objects. - */ - protected function setupTransitionEntityList() { - $transition = $this->prophesize(ModerationStateTransitionInterface::class); - $transition->id()->willReturn('draft__needs_review'); - $transition->getFromState()->willReturn('draft'); - $transition->getToState()->willReturn('needs_review'); - $list[$transition->reveal()->id()] = $transition->reveal(); - - $transition = $this->prophesize(ModerationStateTransitionInterface::class); - $transition->id()->willReturn('needs_review__staging'); - $transition->getFromState()->willReturn('needs_review'); - $transition->getToState()->willReturn('staging'); - $list[$transition->reveal()->id()] = $transition->reveal(); - - $transition = $this->prophesize(ModerationStateTransitionInterface::class); - $transition->id()->willReturn('staging__published'); - $transition->getFromState()->willReturn('staging'); - $transition->getToState()->willReturn('published'); - $list[$transition->reveal()->id()] = $transition->reveal(); - - $transition = $this->prophesize(ModerationStateTransitionInterface::class); - $transition->id()->willReturn('needs_review__draft'); - $transition->getFromState()->willReturn('needs_review'); - $transition->getToState()->willReturn('draft'); - $list[$transition->reveal()->id()] = $transition->reveal(); - - $transition = $this->prophesize(ModerationStateTransitionInterface::class); - $transition->id()->willReturn('draft__draft'); - $transition->getFromState()->willReturn('draft'); - $transition->getToState()->willReturn('draft'); - $list[$transition->reveal()->id()] = $transition->reveal(); - - $transition = $this->prophesize(ModerationStateTransitionInterface::class); - $transition->id()->willReturn('needs_review__needs_review'); - $transition->getFromState()->willReturn('needs_review'); - $transition->getToState()->willReturn('needs_review'); - $list[$transition->reveal()->id()] = $transition->reveal(); - - $transition = $this->prophesize(ModerationStateTransitionInterface::class); - $transition->id()->willReturn('published__published'); - $transition->getFromState()->willReturn('published'); - $transition->getToState()->willReturn('published'); - $list[$transition->reveal()->id()] = $transition->reveal(); - - return $list; - } - - /** - * Builds a mock storage object for States. - * - * @return EntityStorageInterface - * The mocked storage object for States. - */ - protected function setupStateStorage() { - $entity_storage = $this->prophesize(EntityStorageInterface::class); - - $state = $this->prophesize(ModerationStateInterface::class); - $state->id()->willReturn('draft'); - $state->label()->willReturn('Draft'); - $state->isPublishedState()->willReturn(FALSE); - $state->isDefaultRevisionState()->willReturn(FALSE); - $states['draft'] = $state->reveal(); - - $state = $this->prophesize(ModerationStateInterface::class); - $state->id()->willReturn('needs_review'); - $state->label()->willReturn('Needs Review'); - $state->isPublishedState()->willReturn(FALSE); - $state->isDefaultRevisionState()->willReturn(FALSE); - $states['needs_review'] = $state->reveal(); - - $state = $this->prophesize(ModerationStateInterface::class); - $state->id()->willReturn('staging'); - $state->label()->willReturn('Staging'); - $state->isPublishedState()->willReturn(FALSE); - $state->isDefaultRevisionState()->willReturn(FALSE); - $states['staging'] = $state->reveal(); - - $state = $this->prophesize(ModerationStateInterface::class); - $state->id()->willReturn('published'); - $state->label()->willReturn('Published'); - $state->isPublishedState()->willReturn(TRUE); - $state->isDefaultRevisionState()->willReturn(TRUE); - $states['published'] = $state->reveal(); - - $state = $this->prophesize(ModerationStateInterface::class); - $state->id()->willReturn('archived'); - $state->label()->willReturn('Archived'); - $state->isPublishedState()->willReturn(TRUE); - $state->isDefaultRevisionState()->willReturn(TRUE); - $states['archived'] = $state->reveal(); - - $entity_storage->loadMultiple()->willReturn($states); - - foreach ($states as $id => $state) { - $entity_storage->load($id)->willReturn($state); - } - - return $entity_storage->reveal(); - } - - /** - * Builds a mocked Entity Type Manager. - * - * @return EntityTypeManagerInterface - * The mocked Entity Type Manager. - */ - protected function setupEntityTypeManager(EntityStorageInterface $storage) { - $entityTypeManager = $this->prophesize(EntityTypeManagerInterface::class); - $entityTypeManager->getStorage('moderation_state')->willReturn($storage); - $entityTypeManager->getStorage('moderation_state_transition')->willReturn($this->setupTransitionStorage()); - - return $entityTypeManager->reveal(); - } - - /** - * Builds a mocked query factory that does nothing. - * - * @return QueryFactory - * The mocked query factory that does nothing. - */ - protected function setupQueryFactory() { - $factory = $this->prophesize(QueryFactory::class); - - return $factory->reveal(); - } - - /** - * @covers ::isTransitionAllowed - * @covers ::calculatePossibleTransitions - * - * @dataProvider providerIsTransitionAllowedWithValidTransition - */ - public function testIsTransitionAllowedWithValidTransition($from_id, $to_id) { - $storage = $this->setupStateStorage(); - $state_transition_validation = new StateTransitionValidation($this->setupEntityTypeManager($storage), $this->setupQueryFactory()); - $this->assertTrue($state_transition_validation->isTransitionAllowed($storage->load($from_id), $storage->load($to_id))); - } - - /** - * Data provider for self::testIsTransitionAllowedWithValidTransition(). - */ - public function providerIsTransitionAllowedWithValidTransition() { - return [ - ['draft', 'draft'], - ['draft', 'needs_review'], - ['needs_review', 'needs_review'], - ['needs_review', 'staging'], - ['staging', 'published'], - ['needs_review', 'draft'], - ]; - } - - /** - * @covers ::isTransitionAllowed - * @covers ::calculatePossibleTransitions - * - * @dataProvider providerIsTransitionAllowedWithInValidTransition - */ - public function testIsTransitionAllowedWithInValidTransition($from_id, $to_id) { - $storage = $this->setupStateStorage(); - $state_transition_validation = new StateTransitionValidation($this->setupEntityTypeManager($storage), $this->setupQueryFactory()); - $this->assertFalse($state_transition_validation->isTransitionAllowed($storage->load($from_id), $storage->load($to_id))); - } - - /** - * Data provider for self::testIsTransitionAllowedWithInValidTransition(). - */ - public function providerIsTransitionAllowedWithInValidTransition() { - return [ - ['published', 'needs_review'], - ['published', 'staging'], - ['staging', 'needs_review'], - ['staging', 'staging'], - ['needs_review', 'published'], - ['published', 'archived'], - ['archived', 'published'], - ]; - } - - /** * Verifies user-aware transition validation. * * @param string $from_id @@ -239,7 +30,7 @@ public function providerIsTransitionAllowedWithInValidTransition() { * @param bool $allowed * Whether or not to grant a user this permission. * @param bool $result - * Whether userMayTransition() is expected to return TRUE or FALSE. + * Whether getValidTransitions() is expected to have the. * * @dataProvider userTransitionsProvider */ @@ -250,10 +41,46 @@ public function testUserSensitiveValidTransitions($from_id, $to_id, $permission, $user->hasPermission($permission)->willReturn($allowed); $user->hasPermission(Argument::type('string'))->willReturn(FALSE); - $storage = $this->setupStateStorage(); - $validator = new Validator($this->setupEntityTypeManager($storage), $this->setupQueryFactory()); + $entity = $this->prophesize(ContentEntityInterface::class); + $entity = $entity->reveal(); + $entity->moderation_state = new \stdClass(); + $entity->moderation_state->value = $from_id; + + $validator = new StateTransitionValidation($this->setUpModerationInformation($entity)); + $has_transition = FALSE; + foreach ($validator->getValidTransitions($entity, $user->reveal()) as $transition) { + if ($transition->to()->id() === $to_id) { + $has_transition = TRUE; + break; + } + } + $this->assertSame($result, $has_transition); + } - $this->assertEquals($result, $validator->userMayTransition($storage->load($from_id), $storage->load($to_id), $user->reveal())); + protected function setUpModerationInformation(ContentEntityInterface $entity) { + // Create a container so that the plugin manager and workflow type can be + // mocked. + $container = new ContainerBuilder(); + $workflow_type = $this->prophesize(WorkflowTypeInterface::class); + $workflow_type->decorateState(Argument::any())->willReturnArgument(0); + $workflow_type->decorateTransition(Argument::any())->willReturnArgument(0); + $workflow_manager = $this->prophesize(WorkflowTypeManager::class); + $workflow_manager->createInstance('content_moderation', Argument::any())->willReturn($workflow_type->reveal()); + $container->set('core.workflow_type_manager', $workflow_manager->reveal()); + \Drupal::setContainer($container); + + $workflow = new Workflow(['id' => 'process', 'type' => 'content_moderation'], 'workflow'); + $workflow + ->addState('draft', 'draft') + ->addState('needs_review', 'needs_review') + ->addState('published', 'published') + ->addTransition('draft', 'draft', 'draft') + ->addTransition('draft', 'needs_review', 'review') + ->addTransition('needs_review', 'published', 'publish') + ->addTransition('published', 'published', 'publish'); + $moderation_info = $this->prophesize(ModerationInformationInterface::class); + $moderation_info->getWorkFlowForEntity($entity)->willReturn($workflow); + return $moderation_info->reveal(); } /** @@ -261,37 +88,18 @@ public function testUserSensitiveValidTransitions($from_id, $to_id, $permission, */ public function userTransitionsProvider() { // The user has the right permission, so let it through. - $ret[] = ['draft', 'draft', 'use draft__draft transition', TRUE, TRUE]; + $ret[] = ['draft', 'draft', 'use process transition from draft to draft', TRUE, TRUE]; // The user doesn't have the right permission, block it. - $ret[] = ['draft', 'draft', 'use draft__draft transition', FALSE, FALSE]; + $ret[] = ['draft', 'draft', 'use process transition from draft to draft', FALSE, FALSE]; // The user has some other permission that doesn't matter. - $ret[] = ['draft', 'draft', 'use draft__needs_review transition', TRUE, FALSE]; + $ret[] = ['draft', 'draft', 'use process transition from draft to needs_review', TRUE, FALSE]; // The user has permission, but the transition isn't allowed anyway. - $ret[] = ['published', 'needs_review', 'use published__needs_review transition', TRUE, FALSE]; + $ret[] = ['published', 'needs_review', 'use process transition from published to needs_review', TRUE, FALSE]; return $ret; } } - -/** - * Testable subclass for selected tests. - * - * EntityQuery is beyond untestable, so we have to subclass and override the - * method that uses it. - */ -class Validator extends StateTransitionValidation { - - /** - * {@inheritdoc} - */ - protected function getTransitionFromStates(ModerationStateInterface $from, ModerationStateInterface $to) { - if ($from->id() === 'draft' && $to->id() === 'draft') { - return $this->transitionStorage()->loadMultiple(['draft__draft'])[0]; - } - } - -} diff --git a/core/modules/system/system.install b/core/modules/system/system.install index e8f45eb..b242dc9 100644 --- a/core/modules/system/system.install +++ b/core/modules/system/system.install @@ -1766,5 +1766,13 @@ function system_update_8201() { } /** + * Adds the workflow entity type. + */ +function system_update_8202() { + $workflow_type = \Drupal::entityManager()->getDefinition('workflow'); + \Drupal::entityDefinitionUpdateManager()->installEntityType($workflow_type); +} + +/** * @} End of "addtogroup updates-8.2.0". */ diff --git a/core/modules/system/tests/modules/entity_test/config/schema/entity_test.schema.yml b/core/modules/system/tests/modules/entity_test/config/schema/entity_test.schema.yml index 464a007..d34d949 100644 --- a/core/modules/system/tests/modules/entity_test/config/schema/entity_test.schema.yml +++ b/core/modules/system/tests/modules/entity_test/config/schema/entity_test.schema.yml @@ -27,19 +27,3 @@ entity_test.entity_test_bundle.*: description: type: text label: 'Description' - -entity_test.entity_test_bundle.*.third_party.content_moderation: - type: mapping - label: 'Enable moderation states for this entity test type' - mapping: - enabled: - type: boolean - label: 'Moderation states enabled' - allowed_moderation_states: - type: sequence - sequence: - type: string - label: 'Moderation state' - default_moderation_state: - type: string - label: 'Moderation state for new entity test' diff --git a/core/modules/system/tests/modules/workflow_type_test/config/schema/workflow_type_test.schema.yml b/core/modules/system/tests/modules/workflow_type_test/config/schema/workflow_type_test.schema.yml new file mode 100644 index 0000000..4f12fdd --- /dev/null +++ b/core/modules/system/tests/modules/workflow_type_test/config/schema/workflow_type_test.schema.yml @@ -0,0 +1,33 @@ +workflow.type_settings.workflow_type_test: + type: mapping + label: 'Workflow test type settings' + mapping: + states: + type: sequence + sequence: + type: ignore + +workflow.type_settings.workflow_type_complex_test: + type: mapping + label: 'Workflow complex test type settings' + mapping: + states: + type: sequence + label: 'Additional state configuration' + sequence: + type: mapping + label: 'States' + mapping: + extra: + type: string + label: 'Extra information' + transitions: + type: sequence + label: 'Additional transition configuration' + sequence: + type: mapping + label: 'Transitions' + mapping: + extra: + type: string + label: 'Extra information' diff --git a/core/modules/system/tests/modules/workflow_type_test/src/DecoratedState.php b/core/modules/system/tests/modules/workflow_type_test/src/DecoratedState.php new file mode 100644 index 0000000..dfd1169 --- /dev/null +++ b/core/modules/system/tests/modules/workflow_type_test/src/DecoratedState.php @@ -0,0 +1,90 @@ +state = $state; + $this->extra = $extra; + } + + /** + * Gets the extra information stored on the state. + * + * @return string + */ + public function getExtra() { + return $this->extra; + } + + /** + * {@inheritdoc} + */ + public function id() { + return $this->state->id(); + } + + /** + * {@inheritdoc} + */ + public function label() { + return $this->state->label(); + } + + /** + * {@inheritdoc} + */ + public function weight() { + return $this->state->weight(); + } + + /** + * {@inheritdoc} + */ + public function canTransitionTo($to_state_id) { + return $this->state->canTransitionTo($to_state_id); + } + + /** + * {@inheritdoc} + */ + public function getTransitionTo($to_state_id) { + return $this->state->getTransitionTo($to_state_id); + } + + /** + * {@inheritdoc} + */ + public function getTransitions() { + return $this->state->getTransitions(); + } + +} diff --git a/core/modules/system/tests/modules/workflow_type_test/src/DecoratedTransition.php b/core/modules/system/tests/modules/workflow_type_test/src/DecoratedTransition.php new file mode 100644 index 0000000..3777f40 --- /dev/null +++ b/core/modules/system/tests/modules/workflow_type_test/src/DecoratedTransition.php @@ -0,0 +1,76 @@ +transition = $transition; + $this->extra = $extra; + } + + /** + * Gets the extra information stored on the transition. + * + * @return string + */ + public function getExtra() { + return $this->extra; + } + + /** + * {@inheritdoc} + */ + public function id() { + return $this->transition->id(); + } + + /** + * {@inheritdoc} + */ + public function label() { + return $this->transition->label(); + } + + /** + * {@inheritdoc} + */ + public function from() { + return $this->transition->from(); + } + + /** + * {@inheritdoc} + */ + public function to() { + return $this->transition->to(); + } + +} diff --git a/core/modules/system/tests/modules/workflow_type_test/src/Plugin/WorkflowType/ComplexTestType.php b/core/modules/system/tests/modules/workflow_type_test/src/Plugin/WorkflowType/ComplexTestType.php new file mode 100644 index 0000000..d6a504a --- /dev/null +++ b/core/modules/system/tests/modules/workflow_type_test/src/Plugin/WorkflowType/ComplexTestType.php @@ -0,0 +1,82 @@ +configuration['states'][$state->id()])) { + $state = new DecoratedState($state, $this->configuration['states'][$state->id()]['extra']); + } + else { + $state = new DecoratedState($state); + } + return $state; + } + + /** + * {@inheritDoc} + */ + public function decorateTransition(TransitionInterface $transition) { + if (isset($this->configuration['transitions'][$transition->id()])) { + $transition = new DecoratedTransition($transition, $this->configuration['transitions'][$transition->id()]['extra']); + } + else { + $transition = new DecoratedTransition($transition); + } + return $transition; + } + + /** + * {@inheritdoc} + */ + public function buildStateConfigurationForm(FormStateInterface $form_state, WorkflowInterface $workflow, StateInterface $state = NULL) { + /** @var \Drupal\workflow_type_test\DecoratedState $state */ + $form = []; + $form['extra'] = [ + '#type' => 'textfield', + '#title' => $this->t('Extra'), + '#description' => $this->t('Extra information added to state'), + '#default_value' => isset($state) ? $state->getExtra() : FALSE, + ]; + return $form; + } + + /** + * {@inheritdoc} + */ + public function buildTransitionConfigurationForm(FormStateInterface $form_state, WorkflowInterface $workflow, TransitionInterface $transition = NULL) { + /** @var \Drupal\workflow_type_test\DecoratedTransition $transition */ + $form = []; + $form['extra'] = [ + '#type' => 'textfield', + '#title' => $this->t('Extra'), + '#description' => $this->t('Extra information added to transition'), + '#default_value' => isset($transition) ? $transition->getExtra() : FALSE, + ]; + return $form; + } + +} diff --git a/core/modules/system/tests/modules/workflow_type_test/src/Plugin/WorkflowType/TestType.php b/core/modules/system/tests/modules/workflow_type_test/src/Plugin/WorkflowType/TestType.php new file mode 100644 index 0000000..59115cc --- /dev/null +++ b/core/modules/system/tests/modules/workflow_type_test/src/Plugin/WorkflowType/TestType.php @@ -0,0 +1,16 @@ +workflowTypePluginManager = $workflow_type_plugin_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('core.workflow_type_manager') + ); + } + + /** + * {@inheritdoc} + */ + public function form(array $form, FormStateInterface $form_state) { + $form = parent::form($form, $form_state); + + /* @var \Drupal\Core\Workflow\WorkflowInterface $workflow */ + $workflow = $this->entity; + $form['label'] = [ + '#type' => 'textfield', + '#title' => $this->t('Label'), + '#maxlength' => 255, + '#default_value' => $workflow->label(), + '#description' => $this->t('Label for the Workflow.'), + '#required' => TRUE, + ]; + + $form['id'] = [ + '#type' => 'machine_name', + '#default_value' => $workflow->id(), + '#machine_name' => [ + 'exists' => [Workflow::class, 'load'], + ], + ]; + + $workflow_types = array_map(function ($plugin_definition) { + return $plugin_definition['label']; + }, $this->workflowTypePluginManager->getDefinitions()); + $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\Core\Workflow\WorkflowInterface $workflow */ + $workflow = $this->entity; + $workflow->save(); + drupal_set_message($this->t('Created the %label Workflow.', [ + '%label' => $workflow->label(), + ])); + $form_state->setRedirectUrl($workflow->toUrl()); + } + + /** + * {@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\Core\Workflow\WorkflowInterface $entity */ + $values = $form_state->getValues(); + $entity->set('label', $values['label']); + $entity->set('id', $values['id']); + $entity->set('type', $values['workflow_type']); + } + +} diff --git a/core/modules/content_moderation/src/Form/ModerationStateDeleteForm.php b/core/modules/workflow_ui/src/Form/WorkflowDeleteForm.php similarity index 64% rename from core/modules/content_moderation/src/Form/ModerationStateDeleteForm.php rename to core/modules/workflow_ui/src/Form/WorkflowDeleteForm.php index 43e2b36..446aaaa 100644 --- a/core/modules/content_moderation/src/Form/ModerationStateDeleteForm.php +++ b/core/modules/workflow_ui/src/Form/WorkflowDeleteForm.php @@ -1,28 +1,28 @@ t('Are you sure you want to delete %name?', array('%name' => $this->entity->label())); + return $this->t('Are you sure you want to delete %name?', ['%name' => $this->entity->label()]); } /** * {@inheritdoc} */ public function getCancelUrl() { - return new Url('entity.moderation_state.collection'); + return new Url('entity.workflow.collection'); } /** @@ -39,7 +39,7 @@ public function submitForm(array &$form, FormStateInterface $form_state) { $this->entity->delete(); drupal_set_message($this->t( - 'Moderation state %label deleted.', + 'Workflow %label deleted.', ['%label' => $this->entity->label()] )); diff --git a/core/modules/workflow_ui/src/Form/WorkflowEditForm.php b/core/modules/workflow_ui/src/Form/WorkflowEditForm.php new file mode 100644 index 0000000..c14e38d --- /dev/null +++ b/core/modules/workflow_ui/src/Form/WorkflowEditForm.php @@ -0,0 +1,179 @@ +entity; + $form['label'] = [ + '#type' => 'textfield', + '#title' => $this->t('Label'), + '#maxlength' => 255, + '#default_value' => $workflow->label(), + '#description' => $this->t('Label for the Workflow.'), + '#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, + '#collapsible' => 'FALSE', + ]; + $form['states_container']['description'] = [ + '#prefix' => '

', + '#markup' => $this->t('@todo describe what a state is.'), + '#suffix' => '

', + ]; + $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', + ], + ], + ]; + foreach ($workflow->getStates() as $state) { + $links['edit'] = [ + 'title' => $this->t('Edit'), + 'url' => Url::fromRoute('entity.workflow.edit_state_form', ['workflow' => $workflow->id(), 'workflow_state' => $state->id()]), + 'attributes' => ['aria-label' => $this->t('Edit @state state', ['@state' => $state->label()])], + ]; + $links['delete'] = [ + 'title' => t('Delete'), + 'url' => Url::fromRoute('entity.workflow.delete_state_form', ['workflow' => $workflow->id(), 'workflow_state' => $state->id()]), + 'attributes' => ['aria-label' => $this->t('Delete @state state', ['@state' => $state->label()])], + ]; + $form['states_container']['states'][$state->id()] = [ + '#attributes' => ['class' => ['draggable']], + 'state' => ['#markup' => $state->label()], + '#weight' => $state->weight(), + 'weight' => [ + '#type' => 'weight', + '#title' => t('Weight for @title', ['@title' => $state->label()]), + '#title_display' => 'invisible', + '#default_value' => $state->weight(), + '#attributes' => ['class' => ['state-weight']], + ], + 'operations' => [ + '#type' => 'operations', + '#links' => $links, + ], + ]; + } + $form['states_container']['state_add'] = [ + '#markup' => $workflow->toLink($this->t('Add a new state'), 'add-state-form')->toString(), + ]; + + $header = [ + 'state' => $this->t('From'), + 'weight' => $this->t('To'), + 'label' => $this->t('Label'), + 'operations' => $this->t('Operations') + ]; + $form['transitions_container'] = [ + '#type' => 'details', + '#title' => $this->t('Transitions'), + '#open' => TRUE, + ]; + $form['transitions_container']['description'] = [ + '#prefix' => '

', + '#markup' => $this->t('@todo describe what a transition is.'), + '#suffix' => '

', + ]; + $form['transitions_container']['transitions'] = [ + '#type' => 'table', + '#header' => $header, + '#title' => $this->t('Transitions'), + '#empty' => $this->t('There are no transitions yet.'), + ]; + foreach ($workflow->getTransitions() as $transition) { + $links['edit'] = [ + 'title' => $this->t('Edit'), + 'url' => Url::fromRoute('entity.workflow.edit_transition_form', ['workflow' => $workflow->id(), 'workflow_transition' => $transition->id()]), + 'attributes' => ['aria-label' => $this->t('Edit transition from @from_state to @to_state', ['@from_state' => $transition->from()->label(), '@to_state' => $transition->to()->label()])], + ]; + $links['delete'] = [ + 'title' => t('Delete'), + 'url' => Url::fromRoute('entity.workflow.delete_transition_form', ['workflow' => $workflow->id(), 'workflow_transition' => $transition->id()]), + 'attributes' => ['aria-label' => $this->t('Delete transition from @from_state to @to_state', ['@from_state' => $transition->from()->label(), '@to_state' => $transition->to()->label()])], + ]; + $form['transitions_container']['transitions'][$transition->id()] = [ + 'to' => ['#markup' => $transition->from()->label()], + 'from' => ['#markup' => $transition->to()->label()], + 'label' => ['#markup' => $transition->label()], + 'operations' => [ + '#type' => 'operations', + '#links' => $links, + ], + ]; + } + $form['transitions_container']['transition_add'] = [ + '#markup' => $workflow->toLink($this->t('Add a new transition'), 'add-transition-form')->toString(), + ]; + + return $form; + } + + /** + * {@inheritdoc} + */ + public function save(array $form, FormStateInterface $form_state) { + /* @var \Drupal\Core\Workflow\WorkflowInterface $workflow */ + $workflow = $this->entity; + $workflow->save(); + drupal_set_message($this->t('Saved the %label Workflow.', ['%label' => $workflow->label()])); + $form_state->setRedirectUrl($workflow->toUrl('collection')); + } + + /** + * {@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\Core\Workflow\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->setStateWeight($state_id, $state_values['weight']); + } + } + +} diff --git a/core/modules/workflow_ui/src/Form/WorkflowStateAddForm.php b/core/modules/workflow_ui/src/Form/WorkflowStateAddForm.php new file mode 100644 index 0000000..2003a71 --- /dev/null +++ b/core/modules/workflow_ui/src/Form/WorkflowStateAddForm.php @@ -0,0 +1,126 @@ +getEntity(); + $form['label'] = [ + '#type' => 'textfield', + '#title' => $this->t('Label'), + '#maxlength' => 255, + '#default_value' => '', + '#description' => $this->t('Label for the state.'), + '#required' => TRUE, + ]; + + $form['id'] = [ + '#type' => 'machine_name', + '#machine_name' => [ + 'exists' => [$this, 'exists'], + ], + ]; + + $form['transition_to_self_label'] = [ + '#type' => 'textfield', + '#title' => $this->t('Transition to self label'), + '#maxlength' => 255, + '#default_value' => '', + '#description' => $this->t('Label for the transition to self. If this is left blank a transition will not be created.'), + ]; + + // Add additional form fields from the workflow type plugin. + $form['type_settings'] = [ + $workflow->get('type') => $workflow->getTypePlugin()->buildStateConfigurationForm($form_state, $workflow), + '#tree' => TRUE, + ]; + + 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\Core\Workflow\WorkflowInterface $original_workflow */ + $original_workflow = \Drupal::entityTypeManager()->getStorage('workflow')->loadUnchanged($this->getEntity()->id()); + return $original_workflow->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) { + /** @var \Drupal\Core\Workflow\WorkflowInterface $entity */ + $values = $form_state->getValues(); + + // This is fired twice so we have to check that the entity does not already + // have the state. + if (!$entity->hasState($values['id'])) { + $entity->addState($values['id'], $values['label']); + if (isset($values['type_settings'])) { + $configuration = $entity->getTypePlugin()->getConfiguration(); + $configuration['states'][$values['id']] = $values['type_settings'][$entity->getTypePlugin()->getPluginId()]; + $entity->set('type_settings', $configuration); + } + if ($values['transition_to_self_label'] !== '') { + $entity->addTransition($values['id'], $values['id'], $values['transition_to_self_label']); + } + } + } + + /** + * {@inheritdoc} + */ + public function save(array $form, FormStateInterface $form_state) { + /** @var \Drupal\Core\Workflow\WorkflowInterface $workflow */ + $workflow = $this->entity; + $workflow->save(); + drupal_set_message($this->t('Created %label state.', [ + '%label' => $workflow->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; + } + +} diff --git a/core/modules/workflow_ui/src/Form/WorkflowStateDeleteForm.php b/core/modules/workflow_ui/src/Form/WorkflowStateDeleteForm.php new file mode 100644 index 0000000..51a1ef7 --- /dev/null +++ b/core/modules/workflow_ui/src/Form/WorkflowStateDeleteForm.php @@ -0,0 +1,99 @@ +t('Are you sure you want to delete %state from %workflow?', ['%state' => $this->workflow->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\Core\Workflow\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->hasState($workflow_state)) { + throw new NotFoundHttpException(); + } + $this->workflow = $workflow; + $this->stateId = $workflow_state; + return parent::buildForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + + $workflow_label = $this->workflow->getState($this->stateId)->label(); + $this->workflow + ->deleteState($this->stateId) + ->save(); + + drupal_set_message($this->t( + 'State %label deleted.', + ['%label' => $workflow_label] + )); + + $form_state->setRedirectUrl($this->getCancelUrl()); + } + +} diff --git a/core/modules/workflow_ui/src/Form/WorkflowStateEditForm.php b/core/modules/workflow_ui/src/Form/WorkflowStateEditForm.php new file mode 100644 index 0000000..606cdd7 --- /dev/null +++ b/core/modules/workflow_ui/src/Form/WorkflowStateEditForm.php @@ -0,0 +1,168 @@ +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\Core\Workflow\WorkflowInterface $workflow */ + $workflow = $this->getEntity(); + $state = $workflow->getState($this->stateId); + $form['label'] = [ + '#type' => 'textfield', + '#title' => $this->t('Label'), + '#maxlength' => 255, + '#default_value' => $state->label(), + '#description' => $this->t('Label for the state.'), + '#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. + $form['type_settings'] = [ + $workflow->get('type') => $workflow->getTypePlugin()->buildStateConfigurationForm($form_state, $workflow, $state), + '#tree' => TRUE, + ]; + + $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 states 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' => 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) { + /** @var \Drupal\Core\Workflow\WorkflowInterface $entity */ + $values = $form_state->getValues(); + $entity->setStateLabel($values['id'], $values['label']); + if (isset($values['type_settings'])) { + $configuration = $entity->getTypePlugin()->getConfiguration(); + $configuration['states'][$values['id']] = $values['type_settings'][$entity->getTypePlugin()->getPluginId()]; + $entity->set('type_settings', $configuration); + } + } + + /** + * {@inheritdoc} + */ + public function save(array $form, FormStateInterface $form_state) { + /** @var \Drupal\Core\Workflow\WorkflowInterface $workflow */ + $workflow = $this->entity; + $workflow->save(); + drupal_set_message($this->t('Saved %label state.', [ + '%label' => $workflow->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'), + // Deleting a state is editing a workflow. + '#access' => $this->entity->access('edit'), + '#attributes' => [ + 'class' => ['button', 'button--danger'], + ], + '#url' => Url::fromRoute('entity.workflow.delete_state_form', [ + 'workflow' => $this->entity->id(), + 'workflow_state' => $this->stateId + ]) + ]; + + return $actions; + } + +} diff --git a/core/modules/workflow_ui/src/Form/WorkflowTransitionAddForm.php b/core/modules/workflow_ui/src/Form/WorkflowTransitionAddForm.php new file mode 100644 index 0000000..8bf29cf --- /dev/null +++ b/core/modules/workflow_ui/src/Form/WorkflowTransitionAddForm.php @@ -0,0 +1,133 @@ +getEntity(); + $form['label'] = [ + '#type' => 'textfield', + '#title' => $this->t('Label'), + '#maxlength' => 255, + '#default_value' => '', + '#description' => $this->t('Label for the transition.'), + '#required' => TRUE, + ]; + + // @todo add some ajax to ensure that only valid transitions are selectable. + $states = array_map([State::class, 'labelCallback'], $workflow->getStates()); + $form['from'] = [ + '#type' => 'select', + '#title' => $this->t('From'), + '#required' => TRUE, + '#default_value' => '', + '#options' => $states, + ]; + $form['to'] = [ + '#type' => 'select', + '#title' => $this->t('To'), + '#required' => TRUE, + '#default_value' => '', + '#options' => $states, + ]; + + // Add additional form fields from the workflow type plugin. + $form['type_settings'] = [ + $workflow->get('type') => $workflow->getTypePlugin()->buildTransitionConfigurationForm($form_state, $workflow), + '#tree' => TRUE, + ]; + + 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) { + /** @var \Drupal\Core\Workflow\WorkflowInterface $entity */ + $values = $form_state->getValues(); + if (!$entity->hasTransitionFromStateToState($values['from'], $values['to'])) { + // This method is called twice and before the form validation so we need + // to store what happened here in order to validate. + $form_state->set('created_transition', TRUE); + $entity->addTransition($values['from'], $values['to'], $values['label']); + $transition_id = $entity->getTransitionFromStateToState($values['from'], $values['to'])->id(); + if (isset($values['type_settings'])) { + $configuration = $entity->getTypePlugin()->getConfiguration(); + $configuration['transitions'][$transition_id] = $values['type_settings'][$entity->getTypePlugin()->getPluginId()]; + $entity->set('type_settings', $configuration); + } + } + } + + + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + // If the form created the transition then it has already been validated. + if ($form_state->has('created_transition') && $form_state->get('created_transition')) { + return; + } + /** @var \Drupal\Core\Workflow\WorkflowInterface $workflow */ + $workflow = $this->getEntity(); + $values = $form_state->getValues(); + // If the form is for a new transition it should not exist. + if ($workflow->hasTransitionFromStateToState($values['from'], $values['to'])) { + $form_state->setErrorByName('from', $this->t('The transition from %from to %to already exists.', [ + '%from' => $workflow->getState($values['from'])->label(), + '%to' => $workflow->getState($values['to'])->label(), + ])); + } + } + + /** + * {@inheritdoc} + */ + public function save(array $form, FormStateInterface $form_state) { + /** @var \Drupal\Core\Workflow\WorkflowInterface $workflow */ + $workflow = $this->entity; + $workflow->save(); + drupal_set_message($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; + } + +} diff --git a/core/modules/workflow_ui/src/Form/WorkflowTransitionDeleteForm.php b/core/modules/workflow_ui/src/Form/WorkflowTransitionDeleteForm.php new file mode 100644 index 0000000..993b501 --- /dev/null +++ b/core/modules/workflow_ui/src/Form/WorkflowTransitionDeleteForm.php @@ -0,0 +1,102 @@ +t('Are you sure you want to delete the transition from %from to %to from %workflow?', ['%from' => $this->transition->from()->label(), '%to' => $this->transition->to()->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\Core\Workflow\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->getTransition($workflow_transition); + } + catch (\InvalidArgumentException $e) { + throw new NotFoundHttpException(); + } + $this->workflow = $workflow; + return parent::buildForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $this->workflow + ->deleteTransition($this->transition->id()) + ->save(); + + drupal_set_message($this->t('Transition from %from to %to deleted.', ['%from' => $this->transition->from()->label(), '%to' => $this->transition->to()->label()])); + $form_state->setRedirectUrl($this->getCancelUrl()); + } + +} diff --git a/core/modules/workflow_ui/src/Form/WorkflowTransitionEditForm.php b/core/modules/workflow_ui/src/Form/WorkflowTransitionEditForm.php new file mode 100644 index 0000000..b42dd49 --- /dev/null +++ b/core/modules/workflow_ui/src/Form/WorkflowTransitionEditForm.php @@ -0,0 +1,147 @@ +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\Core\Workflow\WorkflowInterface $workflow */ + $workflow = $this->getEntity(); + $transition = $workflow->getTransition($this->transitionId); + $form['label'] = [ + '#type' => 'textfield', + '#title' => $this->t('Label'), + '#maxlength' => 255, + '#default_value' => $transition->label(), + '#description' => $this->t('Label for the transition.'), + '#required' => TRUE, + ]; + + $form['id'] = [ + '#type' => 'hidden', + '#value' => $this->transitionId, + ]; + + // @todo add some ajax to ensure that only valid transitions are selectable. + $states = array_map([State::class, 'labelCallback'], $workflow->getStates()); + $form['from'] = [ + '#type' => 'select', + '#title' => $this->t('From'), + '#required' => TRUE, + '#default_value' => $transition->from()->id(), + '#options' => $states, + '#disabled' => TRUE, + ]; + $form['to'] = [ + '#type' => 'select', + '#title' => $this->t('To'), + '#required' => TRUE, + '#default_value' => $transition->to()->id(), + '#options' => $states, + '#disabled' => TRUE, + ]; + + // Add additional form fields from the workflow type plugin. + $form['type_settings'] = [ + $workflow->get('type') => $workflow->getTypePlugin()->buildTransitionConfigurationForm($form_state, $workflow, $transition), + '#tree' => TRUE, + ]; + + 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) { + /** @var \Drupal\Core\Workflow\WorkflowInterface $entity */ + $values = $form_state->getValues(); + $transition_id = $entity->getTransitionFromStateToState($values['from'], $values['to'])->id(); + $form_state->set('created_transition', FALSE); + $entity->setTransitionLabel($transition_id, $values['label']); + if (isset($values['type_settings'])) { + $configuration = $entity->getTypePlugin()->getConfiguration(); + $configuration['transitions'][$transition_id] = $values['type_settings'][$entity->getTypePlugin()->getPluginId()]; + $entity->set('type_settings', $configuration); + } + } + + /** + * {@inheritdoc} + */ + public function save(array $form, FormStateInterface $form_state) { + /** @var \Drupal\Core\Workflow\WorkflowInterface $workflow */ + $workflow = $this->entity; + $workflow->save(); + drupal_set_message($this->t('Saved %label transition.', [ + '%label' => $workflow->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; + } + +} diff --git a/core/modules/workflow_ui/src/WorkflowListBuilder.php b/core/modules/workflow_ui/src/WorkflowListBuilder.php new file mode 100644 index 0000000..0711128 --- /dev/null +++ b/core/modules/workflow_ui/src/WorkflowListBuilder.php @@ -0,0 +1,102 @@ +get('entity_type.manager')->getStorage($entity_type->id()), + $container->get('core.workflow_type_manager') + ); + } + + /** + * 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\Core\Workflow\WorkflowInterface $entity */ + $row['label'] = $entity->label(); + + $row['type']['data'] = [ + '#markup' => $entity->getTypePlugin()->label() + ]; + + $items = array_map([State::class, 'labelCallback'], $entity->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 Content Moderation module provides a workflow type that enables workflows for content entities.'); + } + return $build; + } + +} diff --git a/core/modules/workflow_ui/tests/src/Functional/WorkflowUiNoTypeTest.php b/core/modules/workflow_ui/tests/src/Functional/WorkflowUiNoTypeTest.php new file mode 100644 index 0000000..8ac0f06 --- /dev/null +++ b/core/modules/workflow_ui/tests/src/Functional/WorkflowUiNoTypeTest.php @@ -0,0 +1,54 @@ +drupalPlaceBlock('local_actions_block'); + } + + /** + * Tests the creation of a workflow through the UI. + */ + public function testWorkflowUiWithNoType() { + $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()->pageTextNotContains('Add workflow'); + + $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 is no Workflow yet.'); + } + +} diff --git a/core/modules/workflow_ui/tests/src/Functional/WorkflowUiTest.php b/core/modules/workflow_ui/tests/src/Functional/WorkflowUiTest.php new file mode 100644 index 0000000..a82ed78 --- /dev/null +++ b/core/modules/workflow_ui/tests/src/Functional/WorkflowUiTest.php @@ -0,0 +1,219 @@ +drupalPlaceBlock('local_actions_block'); + } + + /** + * Tests route access/permissions. + */ + public function testAccess() { + // Create a minimal workflow for testing. + $workflow = Workflow::create(['id' => 'test', 'type' => 'workflow_type_test']); + $workflow + ->addState('published', 'Published') + ->addTransition('published', 'published', 'Publish') + ->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/published-published', + 'admin/config/workflow/workflows/manage/test/transition/published-published/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); + } + } + + /** + * Tests the creation of a workflow through the UI. + */ + public function testWorkflowCreation() { + $workflow_storage = $this->container->get('entity_type.manager')->getStorage('workflow'); + /** @var \Drupal\Core\Workflow\WorkflowInterface $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('There is no Workflow 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()->pageTextContains('There are no states yet.'); + $this->clickLink('Add a new state'); + $this->submitForm(['label' => 'Published', 'id' => 'published', 'transition_to_self_label' => 'Publish'], 'Save'); + $this->assertSession()->pageTextContains('Created Published state.'); + $workflow = $workflow_storage->loadUnchanged('test'); + $this->assertTrue($workflow->getState('published')->canTransitionTo('published'), 'Can transition from published to published'); + + $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->getState('draft')->canTransitionTo('draft'), 'Can not transition from draft to draft'); + + $this->clickLink('Add a new transition'); + $this->submitForm(['label' => 'Publish', 'from' => 'draft', 'to' => 'published'], 'Save'); + $this->assertSession()->pageTextContains('Created Publish transition.'); + $workflow = $workflow_storage->loadUnchanged('test'); + $this->assertTrue($workflow->getState('draft')->canTransitionTo('published'), 'Can transition from draft to published'); + + $this->clickLink('Add a new transition'); + $this->submitForm(['label' => 'Create new draft', 'from' => 'draft', 'to' => 'draft'], 'Save'); + $this->assertSession()->pageTextContains('Created Create new draft transition.'); + $workflow = $workflow_storage->loadUnchanged('test'); + $this->assertTrue($workflow->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('Add a new transition'); + $this->submitForm(['label' => 'Create new draft', 'from' => 'published', 'to' => 'draft'], 'Save'); + $this->assertSession()->pageTextContains('Created Create new draft transition.'); + $workflow = $workflow_storage->loadUnchanged('test'); + $this->assertTrue($workflow->getState('published')->canTransitionTo('draft'), 'Can transition from published to draft'); + + // Try creating a duplicate transition. + $this->clickLink('Add a new transition'); + $this->submitForm(['label' => 'Create new draft', 'from' => 'published', 'to' => 'draft'], 'Save'); + $this->assertSession()->pageTextContains('The transition from Live to Draft already exists.'); + + // Try creating a duplicate state. + $this->drupalGet('admin/config/workflow/workflows/manage/test'); + $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('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->getInitialState()->id()); + $this->drupalGet('admin/config/workflow/workflows/manage/test'); + $this->submitForm(['states[draft][weight]' => '-1'], 'Save'); + // This will take us to the list of workflows, so we need to edit the + // workflow again. + $this->clickLink('Edit'); + $workflow = $workflow_storage->loadUnchanged('test'); + $this->assertEquals('draft', $workflow->getInitialState()->id()); + + // 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->hasState('draft'), 'Draft state deleted'); + $this->assertTrue($workflow->hasState('published'), 'Workflow still has published state'); + + // Delete the published state. + $this->clickLink('Delete'); + $this->assertSession()->pageTextContains('Are you sure you want to delete Live from Test?'); + $this->submitForm([], 'Delete'); + $this->assertSession()->pageTextContains('State Live deleted.'); + $this->assertSession()->pageTextContains('There are no states yet.'); + $workflow = $workflow_storage->loadUnchanged('test'); + $this->assertFalse($workflow->hasState('published'), 'Published state deleted'); + + // Delete the entire workflow. + $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 is no Workflow yet.'); + $this->assertNull($workflow_storage->loadUnchanged('test'), 'The test workflow has been deleted'); + } + + /** + * Tests that workflow types can add form fields to states and transitions. + */ + public function testWorkflowDecoration() { + // Create a minimal workflow for testing. + $workflow = Workflow::create(['id' => 'test', 'type' => 'workflow_type_complex_test']); + $workflow + ->addState('published', 'Published') + ->addTransition('published', 'published', 'Publish') + ->save(); + + $this->assertEquals('', $workflow->getState('published')->getExtra()); + $this->assertEquals('', $workflow->getTransition('published-published')->getExtra()); + + $this->drupalLogin($this->createUser(['administer workflows'])); + + // Add additional state information when editing. + $this->drupalGet('admin/config/workflow/workflows/manage/test/state/published'); + $this->assertSession()->pageTextContains('Extra information added to state'); + $this->submitForm(['type_settings[workflow_type_complex_test][extra]' => 'Extra state information'], 'Save'); + + // Add additional transition information when editing. + $this->drupalGet('admin/config/workflow/workflows/manage/test/transition/published-published'); + $this->assertSession()->pageTextContains('Extra information added to transition'); + $this->submitForm(['type_settings[workflow_type_complex_test][extra]' => 'Extra transition information'], 'Save'); + + $workflow_storage = $this->container->get('entity_type.manager')->getStorage('workflow'); + /** @var \Drupal\Core\Workflow\WorkflowInterface $workflow */ + $workflow = $workflow_storage->loadUnchanged('test'); + $this->assertEquals('Extra state information', $workflow->getState('published')->getExtra()); + $this->assertEquals('Extra transition information', $workflow->getTransition('published-published')->getExtra()); + + // Add additional state information when adding. + $this->drupalGet('admin/config/workflow/workflows/manage/test/add_state'); + $this->assertSession()->pageTextContains('Extra information added to state'); + $this->submitForm(['label' => 'Draft', 'id' => 'draft', 'type_settings[workflow_type_complex_test][extra]' => 'Extra state information on add'], 'Save'); + + // Add additional transition information when adding. + $this->drupalGet('admin/config/workflow/workflows/manage/test/add_transition'); + $this->assertSession()->pageTextContains('Extra information added to transition'); + $this->submitForm(['label' => 'Publish', 'from' => 'draft', 'to' => 'published', 'type_settings[workflow_type_complex_test][extra]' => 'Extra transition information on add'], 'Save'); + + $workflow = $workflow_storage->loadUnchanged('test'); + $this->assertEquals('Extra state information on add', $workflow->getState('draft')->getExtra()); + $this->assertEquals('Extra transition information on add', $workflow->getTransition('draft-published')->getExtra()); + } + +} diff --git a/core/modules/workflow_ui/workflow_ui.info.yml b/core/modules/workflow_ui/workflow_ui.info.yml new file mode 100644 index 0000000..8f49f13 --- /dev/null +++ b/core/modules/workflow_ui/workflow_ui.info.yml @@ -0,0 +1,7 @@ +name: 'Workflow UI' +type: module +description: 'Provides UI for managing workflows. This module can be used with the Content moderation module to add highly cusomisable workflows to content.' +version: VERSION +core: 8.x +package: Core (Experimental) +configure: workflow_ui.overview diff --git a/core/modules/workflow_ui/workflow_ui.links.action.yml b/core/modules/workflow_ui/workflow_ui.links.action.yml new file mode 100644 index 0000000..e3a80f7 --- /dev/null +++ b/core/modules/workflow_ui/workflow_ui.links.action.yml @@ -0,0 +1,5 @@ +entity.workflow.add_form: + route_name: 'entity.workflow.add_form' + title: 'Add workflow' + appears_on: + - entity.workflow.collection diff --git a/core/modules/workflow_ui/workflow_ui.links.menu.yml b/core/modules/workflow_ui/workflow_ui.links.menu.yml new file mode 100644 index 0000000..a6ac512 --- /dev/null +++ b/core/modules/workflow_ui/workflow_ui.links.menu.yml @@ -0,0 +1,7 @@ +# Workflow menu items definition +entity.workflow.collection: + title: 'Workflows' + route_name: entity.workflow.collection + description: 'Configure workflows.' + parent: system.admin_config_workflow + diff --git a/core/modules/workflow_ui/workflow_ui.module b/core/modules/workflow_ui/workflow_ui.module new file mode 100644 index 0000000..5035ef1 --- /dev/null +++ b/core/modules/workflow_ui/workflow_ui.module @@ -0,0 +1,58 @@ +' . t('About') . ''; + $output .= '

' . t('The Workflow UI module provides a UI for creating workflows content. This lets site admins define workflows and their states, and then define transitions between those states. For more information, see the online documentation for the Workflow UI module.', [':workflow' => 'https://www.drupal.org/documentation/modules/workflow_ui']) . '

'; + return $output; + } +} + +/** + * Implements hook_entity_type_build(). + */ +function workflow_ui_entity_type_build(array &$entity_types) { + /** @var $entity_types \Drupal\Core\Entity\EntityTypeInterface[] */ + $entity_types['workflow'] + ->setFormClass('add', WorkflowAddForm::class) + ->setFormClass('edit', WorkflowEditForm::class) + ->setFormClass('delete', WorkflowDeleteForm::class) + ->setFormClass('add-state', WorkflowStateAddForm::class) + ->setFormClass('edit-state', WorkflowStateEditForm::class) + ->setFormClass('delete-state', WorkflowStateDeleteForm::class) + ->setFormClass('add-transition', WorkflowTransitionAddForm::class) + ->setFormClass('edit-transition', WorkflowTransitionEditForm::class) + ->setFormClass('delete-transition', WorkflowTransitionDeleteForm::class) + ->setListBuilderClass(WorkflowListBuilder::class) + ->set('admin_permission', 'administer workflows') + ->setLinkTemplate('add-form', '/admin/config/workflow/workflows/add') + ->setLinkTemplate('edit-form', '/admin/config/workflow/workflows/manage/{workflow}') + ->setLinkTemplate('delete-form', '/admin/config/workflow/workflows/manage/{workflow}/delete') + ->setLinkTemplate('add-state-form', '/admin/config/workflow/workflows/manage/{workflow}/add_state') + ->setLinkTemplate('add-transition-form', '/admin/config/workflow/workflows/manage/{workflow}/add_transition') + ->setLinkTemplate('collection', '/admin/config/workflow/workflows'); +} diff --git a/core/modules/workflow_ui/workflow_ui.permissions.yml b/core/modules/workflow_ui/workflow_ui.permissions.yml new file mode 100644 index 0000000..88573b6 --- /dev/null +++ b/core/modules/workflow_ui/workflow_ui.permissions.yml @@ -0,0 +1,4 @@ +'administer workflows': + title: 'Administer workflows' + description: 'Create and edit workflows.' + 'restrict access': TRUE diff --git a/core/modules/workflow_ui/workflow_ui.routing.yml b/core/modules/workflow_ui/workflow_ui.routing.yml new file mode 100644 index 0000000..f1cfabc --- /dev/null +++ b/core/modules/workflow_ui/workflow_ui.routing.yml @@ -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: + _entity_access: 'workflow.edit' + +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: + _entity_access: 'workflow.edit' + +entity.workflow.delete_state_form: + path: '/admin/config/workflow/workflows/manage/{workflow}/state/{workflow_state}/delete' + defaults: + _form: '\Drupal\workflow_ui\Form\WorkflowStateDeleteForm' + _title: 'Delete state' + requirements: + _entity_access: 'workflow.edit' + +entity.workflow.add_transition_form: + path: '/admin/config/workflow/workflows/manage/{workflow}/add_transition' + defaults: + _entity_form: 'workflow.add-transition' + _title: 'Add state' + requirements: + _entity_access: 'workflow.edit' + +entity.workflow.edit_transition_form: + path: '/admin/config/workflow/workflows/manage/{workflow}/transition/{workflow_transition}' + defaults: + _entity_form: 'workflow.edit-transition' + _title: 'Edit state' + requirements: + _entity_access: 'workflow.edit' + +entity.workflow.delete_transition_form: + path: '/admin/config/workflow/workflows/manage/{workflow}/transition/{workflow_transition}/delete' + defaults: + _form: '\Drupal\workflow_ui\Form\WorkflowTransitionDeleteForm' + _title: 'Delete state' + requirements: + _entity_access: 'workflow.edit' \ No newline at end of file diff --git a/core/tests/Drupal/KernelTests/Core/Workflow/ComplexWorkflowTypeTest.php b/core/tests/Drupal/KernelTests/Core/Workflow/ComplexWorkflowTypeTest.php new file mode 100644 index 0000000..d40fdc0 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Workflow/ComplexWorkflowTypeTest.php @@ -0,0 +1,55 @@ + 'test', 'type' => 'workflow_type_complex_test'], 'workflow'); + $workflow + ->addState('draft', 'Draft') + ->addTransition('draft', 'draft', 'Create new draft'); + $this->assertInstanceOf(DecoratedState::class, $workflow->getState('draft')); + $this->assertInstanceOf(DecoratedTransition::class, $workflow->getTransition('draft-draft')); + } + + /** + * @covers ::loadMultipleByType + */ + public function testLoadMultipleByType() { + $workflow1 = new Workflow(['id' => 'test1', 'type' => 'workflow_type_complex_test'], 'workflow'); + $workflow1->save(); + $workflow2 = new Workflow(['id' => 'test2', 'type' => 'workflow_type_complex_test'], 'workflow'); + $workflow2->save(); + $workflow3 = new Workflow(['id' => 'test3', 'type' => 'workflow_type_test'], 'workflow'); + $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')); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Workflow/StateTest.php b/core/tests/Drupal/Tests/Core/Workflow/StateTest.php new file mode 100644 index 0000000..fc9bb13 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Workflow/StateTest.php @@ -0,0 +1,131 @@ +prophesize(WorkflowTypeInterface::class); + $workflow_type->decorateState(Argument::any())->willReturnArgument(0); + $workflow_type->decorateTransition(Argument::any())->willReturnArgument(0); + $workflow_type->deleteState(Argument::any())->willReturn(NULL); + $workflow_type->deleteTransition(Argument::any())->willReturn(NULL); + $workflow_manager = $this->prophesize(WorkflowTypeManager::class); + $workflow_manager->createInstance('test_type', Argument::any())->willReturn($workflow_type->reveal()); + $container->set('core.workflow_type_manager', $workflow_manager->reveal()); + \Drupal::setContainer($container); + } + + /** + * @covers ::__construct + * @covers ::id + * @covers ::label + * @covers ::weight + */ + public function testGetters() { + $state = new State( + $this->prophesize(WorkflowInterface::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() { + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow + ->addState('draft', 'Draft') + ->addState('published', 'Published') + ->addTransition('draft', 'published', 'Publish'); + $state = $workflow->getState('draft'); + $this->assertTrue($state->canTransitionTo('published')); + $this->assertFalse($state->canTransitionTo('some_other_state')); + + $workflow->deleteTransition('draft-published'); + $this->assertFalse($state->canTransitionTo('published')); + } + + /** + * @covers ::getTransitionTo + */ + public function testGetTransitionTo() { + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow + ->addState('draft', 'Draft') + ->addState('published', 'Published') + ->addTransition('draft', 'published', 'Publish'); + $state = $workflow->getState('draft'); + $transition = $state->getTransitionTo('published'); + $this->assertEquals('Publish', $transition->label()); + } + + /** + * @covers ::getTransitionTo + */ + public function testGetTransitionToException() { + $this->setExpectedException(\InvalidArgumentException::class, "Can not transition to 'published' state"); + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow->addState('draft', 'Draft'); + $state = $workflow->getState('draft'); + $state->getTransitionTo('published'); + } + + /** + * @covers ::getTransitions + */ + public function testGetTransitions() { + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow + ->addState('draft', 'Draft') + ->addState('published', 'Published') + ->addState('archived', 'Archived') + ->addTransition('draft', 'draft', 'Create new draft') + ->addTransition('draft', 'published', 'Publish') + ->addTransition('published', 'archived', 'Archive'); + $state = $workflow->getState('draft'); + $transitions = $state->getTransitions(); + $this->assertCount(2, $transitions); + $this->assertEquals('Create new draft', $transitions['draft-draft']->label()); + $this->assertEquals('Publish', $transitions['draft-published']->label()); + } + + /** + * @covers ::labelCallback + */ + public function testLabelCallback() { + $workflow = $this->prophesize(WorkflowInterface::class)->reveal(); + $states = [ + new State($workflow, 'draft', 'Draft'), + new State($workflow, 'published', 'Published'), + ]; + $this->assertEquals(['Draft', 'Published'], array_map([State::class, 'labelCallback'], $states)); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Workflow/TransitionTest.php b/core/tests/Drupal/Tests/Core/Workflow/TransitionTest.php new file mode 100644 index 0000000..f0f6d31 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Workflow/TransitionTest.php @@ -0,0 +1,71 @@ +prophesize(WorkflowTypeInterface::class); + $workflow_type->decorateState(Argument::any())->willReturnArgument(0); + $workflow_type->decorateTransition(Argument::any())->willReturnArgument(0); + $workflow_manager = $this->prophesize(WorkflowTypeManager::class); + $workflow_manager->createInstance('test_type', Argument::any())->willReturn($workflow_type->reveal()); + $container->set('core.workflow_type_manager', $workflow_manager->reveal()); + \Drupal::setContainer($container); + } + + /** + * @covers ::__construct + * @covers ::id + * @covers ::label + */ + public function testGetters() { + $state = new Transition( + $this->prophesize(WorkflowInterface::class)->reveal(), + 'draft-published', + 'draft', + 'published', + 'Publish' + ); + $this->assertEquals('draft-published', $state->id()); + $this->assertEquals('Publish', $state->label()); + } + + /** + * @covers ::from + * @covers ::to + */ + public function testFromAndTo() { + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow + ->addState('draft', 'Draft') + ->addState('published', 'Published') + ->addTransition('draft', 'published', 'Publish'); + $state = $workflow->getState('draft'); + $transition = $state->getTransitionTo('published'); + $this->assertEquals($state, $transition->from()); + $this->assertEquals($workflow->getState('published'), $transition->to()); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Workflow/WorkflowTest.php b/core/tests/Drupal/Tests/Core/Workflow/WorkflowTest.php new file mode 100644 index 0000000..9639954 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Workflow/WorkflowTest.php @@ -0,0 +1,486 @@ +prophesize(WorkflowTypeInterface::class); + $workflow_type->decorateState(Argument::any())->willReturnArgument(0); + $workflow_type->decorateTransition(Argument::any())->willReturnArgument(0); + $workflow_manager = $this->prophesize(WorkflowTypeManager::class); + $workflow_manager->createInstance('test_type', Argument::any())->willReturn($workflow_type->reveal()); + $container->set('core.workflow_type_manager', $workflow_manager->reveal()); + \Drupal::setContainer($container); + } + + /** + * @covers ::addState + * @covers ::hasState + */ + public function testAddAndHasState() { + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $this->assertFalse($workflow->hasState('draft')); + + // By default states are ordered in the order added. + $workflow->addState('draft', 'Draft'); + $this->assertTrue($workflow->hasState('draft')); + $this->assertFalse($workflow->hasState('published')); + $this->assertEquals(0, $workflow->getState('draft')->weight()); + // Adding a state does not set up a transition to itself. + $this->assertFalse($workflow->hasTransitionFromStateToState('draft', 'draft')); + + // New states are added with a new weight 1 more than the current highest + // weight. + $workflow->addState('published', 'Published'); + $this->assertEquals(1, $workflow->getState('published')->weight()); + } + + /** + * @covers ::addState + */ + public function testAddStateException() { + $this->setExpectedException(\InvalidArgumentException::class, "The state 'draft' already exists in workflow 'test'"); + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow->addState('draft', 'Draft'); + $workflow->addState('draft', 'Draft'); + } + + /** + * @covers ::addState + */ + public function testAddStateInvalidIdException() { + $this->setExpectedException(\InvalidArgumentException::class, "The state ID 'draft-draft' must contain only lowercase letters, numbers, and underscores"); + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow->addState('draft-draft', 'Draft'); + } + + /** + * @covers ::getStates + */ + public function testGetStates() { + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + + // Getting states works when there are none. + $this->assertArrayEquals([], array_keys($workflow->getStates())); + $this->assertArrayEquals([], array_keys($workflow->getStates([]))); + + // By default states are ordered in the order added. + $workflow + ->addState('draft', 'Draft') + ->addState('published', 'Published') + ->addState('archived', 'Archived'); + + // Ensure we're returning state objects. + $this->assertInstanceOf(State::class, $workflow->getStates()['draft']); + + // Passing in no IDs returns all states. + $this->assertArrayEquals(['draft', 'published', 'archived'], array_keys($workflow->getStates())); + + // The order of states is by weight. + $workflow->setStateWeight('published', -1); + $this->assertArrayEquals(['published', 'draft', 'archived'], array_keys($workflow->getStates())); + + // The label is also used for sorting if weights are equal. + $workflow->setStateWeight('archived', 0); + $this->assertArrayEquals(['published', 'archived', 'draft'], array_keys($workflow->getStates())); + + // You can limit the states returned by passing in states IDs. + $this->assertArrayEquals(['archived', 'draft'], array_keys($workflow->getStates(['draft', 'archived']))); + + // An empty array does not load all states. + $this->assertArrayEquals([], array_keys($workflow->getStates([]))); + } + + /** + * @covers ::getStates + */ + public function testGetStatesException() { + $this->setExpectedException(\InvalidArgumentException::class, "The state 'state_that_does_not_exist' does not exist in workflow 'test'"); + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow->getStates(['state_that_does_not_exist']); + } + + /** + * @covers ::getState + */ + public function testGetState() { + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + // By default states are ordered in the order added. + $workflow + ->addState('draft', 'Draft') + ->addState('published', 'Published') + ->addState('archived', 'Archived') + ->addTransition('draft', 'draft', 'Create new draft') + ->addTransition('draft', 'published', 'Publish'); + + // Ensure we're returning state objects and they are set up correctly + $this->assertInstanceOf(State::class, $workflow->getState('draft')); + $this->assertEquals('archived', $workflow->getState('archived')->id()); + $this->assertEquals('Archived', $workflow->getState('archived')->label()); + + $draft = $workflow->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->getState('published')->weight()); + $this->assertEquals(2, $workflow->getState('archived')->weight()); + } + + /** + * @covers ::getState + */ + public function testGetStateException() { + $this->setExpectedException(\InvalidArgumentException::class, "The state 'state_that_does_not_exist' does not exist in workflow 'test'"); + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow->getState('state_that_does_not_exist'); + } + + /** + * @covers ::setStateLabel + */ + public function testSetStateLabel() { + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow->addState('draft', 'Draft'); + $this->assertEquals('Draft', $workflow->getState('draft')->label()); + $workflow->setStateLabel('draft', 'Unpublished'); + $this->assertEquals('Unpublished', $workflow->getState('draft')->label()); + } + + /** + * @covers ::setStateLabel + */ + public function testSetStateLabelException() { + $this->setExpectedException(\InvalidArgumentException::class, "The state 'draft' does not exist in workflow 'test'"); + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow->setStateLabel('draft', 'Draft'); + } + + /** + * @covers ::setStateWeight + */ + public function testSetStateWeight() { + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow->addState('draft', 'Draft'); + $this->assertEquals(0, $workflow->getState('draft')->weight()); + $workflow->setStateWeight('draft', -10); + $this->assertEquals(-10, $workflow->getState('draft')->weight()); + } + + /** + * @covers ::setStateWeight + */ + public function testSetStateWeightException() { + $this->setExpectedException(\InvalidArgumentException::class, "The state 'draft' does not exist in workflow 'test'"); + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow->setStateWeight('draft', 10); + } + + /** + * @covers ::deleteState + */ + public function testDeleteState() { + // Create a container so that the plugin manager and workflow type can be + // mocked and test that + // \Drupal\Core\Workflow\WorkflowTypeInterface::deleteState() is called + // correctly. + $container = new ContainerBuilder(); + $workflow_type = $this->prophesize(WorkflowTypeInterface::class); + $workflow_type->decorateState(Argument::any())->willReturnArgument(0); + $workflow_type->decorateTransition(Argument::any())->willReturnArgument(0); + $workflow_type->deleteState('draft')->shouldBeCalled(); + $workflow_type->deleteTransition('draft-draft')->shouldBeCalled(); + $workflow_type->deleteTransition('draft-published')->shouldBeCalled(); + $workflow_type->deleteTransition('published-draft')->shouldBeCalled(); + $workflow_manager = $this->prophesize(WorkflowTypeManager::class); + $workflow_manager->createInstance('test_type', Argument::any())->willReturn($workflow_type->reveal()); + $container->set('core.workflow_type_manager', $workflow_manager->reveal()); + \Drupal::setContainer($container); + + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow + ->addState('draft', 'Draft') + ->addState('published', 'Published') + ->addTransition('published', 'published', 'Publish') + ->addTransition('draft', 'published', 'Publish') + ->addTransition('draft', 'draft', 'Create new draft') + ->addTransition('published', 'draft', 'Create new draft'); + $this->assertCount(2, $workflow->getStates()); + $this->assertCount(2, $workflow->getState('published')->getTransitions()); + $workflow->deleteState('draft'); + $this->assertFalse($workflow->hasState('draft')); + $this->assertCount(1, $workflow->getStates()); + $this->assertCount(1, $workflow->getState('published')->getTransitions()); + } + + /** + * @covers ::deleteState + */ + public function testDeleteStateException() { + $this->setExpectedException(\InvalidArgumentException::class, "The state 'draft' does not exist in workflow 'test'"); + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow->deleteState('draft'); + } + + /** + * @covers ::getInitialState + */ + public function testGetInitialState() { + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + + // By default states are ordered in the order added. + $workflow + ->addState('draft', 'Draft') + ->addState('published', 'Published') + ->addState('archived', 'Archived'); + + $this->assertEquals('draft', $workflow->getInitialState()->id()); + + // Make published the first state. + $workflow->setStateWeight('published', -1); + $this->assertEquals('published', $workflow->getInitialState()->id()); + } + + /** + * @covers ::addTransition + */ + public function testAddTransition() { + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + + // By default states are ordered in the order added. + $workflow + ->addState('draft', 'Draft') + ->addState('published', 'Published'); + + $this->assertFalse($workflow->getState('draft')->canTransitionTo('published')); + $workflow->addTransition('draft', 'published', 'Publish'); + $this->assertTrue($workflow->getState('draft')->canTransitionTo('published')); + } + + /** + * @covers ::addTransition + */ + public function testAddTransitionMissingFromException() { + $this->setExpectedException(\InvalidArgumentException::class, "The state 'draft' does not exist in workflow 'test'"); + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow->addState('published', 'Published'); + $workflow->addTransition('draft', 'published', 'Publish'); + } + + /** + * @covers ::addTransition + */ + public function testAddTransitionMissingToException() { + $this->setExpectedException(\InvalidArgumentException::class, "The state 'published' does not exist in workflow 'test'"); + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow->addState('draft', 'Draft'); + $workflow->addTransition('draft', 'published', 'Publish'); + } + + /** + * @covers ::getTransitions + */ + public function testGetTransitions() { + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + + // Getting transitions works when there are none. + $this->assertArrayEquals([], array_keys($workflow->getTransitions())); + $this->assertArrayEquals([], array_keys($workflow->getTransitions([]))); + + // By default states are ordered in the order added. + $workflow + ->addState('a', 'A') + ->addState('b', 'B') + ->addTransition('a', 'a', 'A to A') + ->addTransition('a', 'b', 'A to B'); + + // Ensure we're returning transition objects. + $this->assertInstanceOf(Transition::class, $workflow->getTransitions()['a-a']); + + // Passing in no IDs returns all transitions. + $this->assertArrayEquals(['a-a', 'a-b'], array_keys($workflow->getTransitions())); + + // The order of states is by weight. + $workflow->setStateWeight('b', -1); + $this->assertArrayEquals(['a-b', 'a-a'], array_keys($workflow->getTransitions())); + + // If all weights are equal it will fallback to labels. + $workflow->setStateWeight('b', 0); + $this->assertArrayEquals(['a-a', 'a-b'], array_keys($workflow->getTransitions())); + $workflow->setTransitionLabel('a-b', 'A B'); + $this->assertArrayEquals(['a-b', 'a-a'], array_keys($workflow->getTransitions())); + + // You can limit the states returned by passing in states IDs. + $this->assertArrayEquals(['a-a'], array_keys($workflow->getTransitions(['a-a']))); + + // An empty array does not load all states. + $this->assertArrayEquals([], array_keys($workflow->getTransitions([]))); + } + + + /** + * @covers ::getTransition + */ + public function testGetTransition() { + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + // By default states are ordered in the order added. + $workflow + ->addState('draft', 'Draft') + ->addState('published', 'Published') + ->addState('archived', 'Archived') + ->addTransition('draft', 'draft', 'Create new draft') + ->addTransition('draft', 'published', 'Publish'); + + // Ensure we're returning state objects and they are set up correctly + $this->assertInstanceOf(Transition::class, $workflow->getTransition('draft-draft')); + $this->assertEquals('draft-published', $workflow->getTransition('draft-published')->id()); + $this->assertEquals('Publish', $workflow->getTransition('draft-published')->label()); + + $transition = $workflow->getTransition('draft-published'); + $this->assertEquals($workflow->getState('draft'), $transition->from()); + $this->assertEquals($workflow->getState('published'), $transition->to()); + } + + /** + * @covers ::getTransition + */ + public function testGetTransitionException() { + $this->setExpectedException(\InvalidArgumentException::class, "The transition 'transition_that_does_not_exist' does not exist in workflow 'test'"); + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow->getTransition('transition_that_does_not_exist'); + } + + /** + * @covers ::getTransitionsForState + */ + public function testGetTransitionsForState() { + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + // By default states are ordered in the order added. + $workflow + ->addState('draft', 'Draft') + ->addState('published', 'Published') + ->addState('archived', 'Archived') + ->addTransition('draft', 'draft', 'Create new draft') + ->addTransition('draft', 'published', 'Publish') + ->addTransition('published', 'published', 'Publish') + ->addTransition('published', 'archived', 'Archive') + ->addTransition('archived', 'draft', 'Create new draft'); + + $this->assertEquals(['draft-draft', 'draft-published'], array_keys($workflow->getTransitionsForState('draft'))); + $this->assertEquals(['draft-draft', 'archived-draft'], array_keys($workflow->getTransitionsForState('draft', 'to'))); + $this->assertEquals(['archived-draft'], array_keys($workflow->getTransitionsForState('archived', 'from'))); + $this->assertEquals(['published-archived'], array_keys($workflow->getTransitionsForState('archived', 'to'))); + } + + + /** + * @covers ::getTransitionFromStateToState + * @covers ::hasTransitionFromStateToState + */ + public function testGetTransitionsFromStateToState() { + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + // By default states are ordered in the order added. + $workflow + ->addState('draft', 'Draft') + ->addState('published', 'Published') + ->addState('archived', 'Archived') + ->addTransition('draft', 'draft', 'Create new draft') + ->addTransition('draft', 'published', 'Publish') + ->addTransition('published', 'published', 'Publish') + ->addTransition('published', 'archived', 'Archive') + ->addTransition('archived', 'draft', 'Create new draft'); + + $this->assertTrue($workflow->hasTransitionFromStateToState('draft', 'published')); + $this->assertFalse($workflow->hasTransitionFromStateToState('archived', 'archived')); + $transition = $workflow->getTransitionFromStateToState('published', 'archived'); + $this->assertEquals('Archive', $transition->label()); + } + + /** + * @covers ::setTransitionLabel + */ + public function testSetTransitionLabel() { + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow + ->addState('draft', 'Draft') + ->addState('published', 'Published') + ->addTransition('draft', 'published', 'Publish'); + $this->assertEquals('Publish', $workflow->getState('draft')->getTransitionTo('published')->label()); + $workflow->setTransitionLabel('draft-published', 'Publish!'); + $this->assertEquals('Publish!', $workflow->getState('draft')->getTransitionTo('published')->label()); + } + + /** + * @covers ::setTransitionLabel + */ + public function testSetTransitionLabelException() { + $this->setExpectedException(\InvalidArgumentException::class, "The transition 'draft-published' does not exist in workflow 'test'"); + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow->addState('published', 'Published'); + $workflow->setTransitionLabel('draft-published', 'Publish'); + } + + /** + * @covers ::deleteTransition + */ + public function testDeleteTransition() { + // Create a container so that the plugin manager and workflow type can be + // mocked and test that + // \Drupal\Core\Workflow\WorkflowTypeInterface::deleteState() is called + // correctly. + $container = new ContainerBuilder(); + $workflow_type = $this->prophesize(WorkflowTypeInterface::class); + $workflow_type->decorateState(Argument::any())->willReturnArgument(0); + $workflow_type->decorateTransition(Argument::any())->willReturnArgument(0); + $workflow_type->deleteTransition('draft-published')->shouldBeCalled(); + $workflow_manager = $this->prophesize(WorkflowTypeManager::class); + $workflow_manager->createInstance('test_type', Argument::any())->willReturn($workflow_type->reveal()); + $container->set('core.workflow_type_manager', $workflow_manager->reveal()); + \Drupal::setContainer($container); + + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow + ->addState('draft', 'Draft') + ->addState('published', 'Published') + ->addTransition('draft', 'draft', 'Create new draft') + ->addTransition('draft', 'published', 'Publish'); + $this->assertTrue($workflow->getState('draft')->canTransitionTo('published')); + $workflow->deleteTransition('draft-published'); + $this->assertFalse($workflow->getState('draft')->canTransitionTo('published')); + $this->assertTrue($workflow->getState('draft')->canTransitionTo('draft')); + } + + /** + * @covers ::deleteTransition + */ + public function testDeleteTransitionException() { + $this->setExpectedException(\InvalidArgumentException::class, "The transition 'draft-published' does not exist in workflow 'test'"); + $workflow = new Workflow(['id' => 'test', 'type' => 'test_type'], 'workflow'); + $workflow->addState('published', 'Published'); + $workflow->deleteTransition('draft-published'); + } + +}