Version 1
[yaffs-website] / web / core / modules / workflows / src / Entity / Workflow.php
1 <?php
2
3 namespace Drupal\workflows\Entity;
4
5 use Drupal\Core\Config\Entity\ConfigEntityBase;
6 use Drupal\Core\Entity\EntityStorageInterface;
7 use Drupal\Core\Entity\EntityWithPluginCollectionInterface;
8 use Drupal\Core\Plugin\DefaultSingleLazyPluginCollection;
9 use Drupal\workflows\Exception\RequiredStateMissingException;
10 use Drupal\workflows\State;
11 use Drupal\workflows\Transition;
12 use Drupal\workflows\WorkflowInterface;
13
14 /**
15  * Defines the workflow entity.
16  *
17  * @ConfigEntityType(
18  *   id = "workflow",
19  *   label = @Translation("Workflow"),
20  *   label_collection = @Translation("Workflows"),
21  *   handlers = {
22  *     "access" = "Drupal\workflows\WorkflowAccessControlHandler",
23  *     "list_builder" = "Drupal\workflows\WorkflowListBuilder",
24  *     "form" = {
25  *       "add" = "Drupal\workflows\Form\WorkflowAddForm",
26  *       "edit" = "Drupal\workflows\Form\WorkflowEditForm",
27  *       "delete" = "Drupal\workflows\Form\WorkflowDeleteForm",
28  *       "add-state" = "Drupal\workflows\Form\WorkflowStateAddForm",
29  *       "edit-state" = "Drupal\workflows\Form\WorkflowStateEditForm",
30  *       "delete-state" = "Drupal\workflows\Form\WorkflowStateDeleteForm",
31  *       "add-transition" = "Drupal\workflows\Form\WorkflowTransitionAddForm",
32  *       "edit-transition" = "Drupal\workflows\Form\WorkflowTransitionEditForm",
33  *       "delete-transition" = "Drupal\workflows\Form\WorkflowTransitionDeleteForm",
34  *     },
35  *     "route_provider" = {
36  *       "html" = "Drupal\Core\Entity\Routing\AdminHtmlRouteProvider",
37  *     }
38  *   },
39  *   config_prefix = "workflow",
40  *   admin_permission = "administer workflows",
41  *   entity_keys = {
42  *     "id" = "id",
43  *     "label" = "label",
44  *     "uuid" = "uuid",
45  *   },
46  *   links = {
47  *     "add-form" = "/admin/config/workflow/workflows/add",
48  *     "edit-form" = "/admin/config/workflow/workflows/manage/{workflow}",
49  *     "delete-form" = "/admin/config/workflow/workflows/manage/{workflow}/delete",
50  *     "add-state-form" = "/admin/config/workflow/workflows/manage/{workflow}/add_state",
51  *     "add-transition-form" = "/admin/config/workflow/workflows/manage/{workflow}/add_transition",
52  *     "collection" = "/admin/config/workflow/workflows"
53  *   },
54  *   config_export = {
55  *     "id",
56  *     "label",
57  *     "states",
58  *     "transitions",
59  *     "type",
60  *     "type_settings"
61  *   },
62  * )
63  *
64  * @internal
65  *   The workflow system is currently experimental and should only be leveraged
66  *   by experimental modules and development releases of contributed modules.
67  */
68 class Workflow extends ConfigEntityBase implements WorkflowInterface, EntityWithPluginCollectionInterface {
69
70   /**
71    * The Workflow ID.
72    *
73    * @var string
74    */
75   protected $id;
76
77   /**
78    * The Moderation state label.
79    *
80    * @var string
81    */
82   protected $label;
83
84   /**
85    * The states of the workflow.
86    *
87    * The array key is the machine name for the state. The structure of each
88    * array item is:
89    * @code
90    *   label: {translatable label}
91    *   weight: {integer value}
92    * @endcode
93    *
94    * @var array
95    */
96   protected $states = [];
97
98   /**
99    * The permitted transitions of the workflow.
100    *
101    * The array key is the machine name for the transition. The machine name is
102    * generated from the machine names of the states. The structure of each array
103    * item is:
104    * @code
105    *   from:
106    *     - {state machine name}
107    *     - {state machine name}
108    *   to: {state machine name}
109    *   label: {translatable label}
110    * @endcode
111    *
112    * @var array
113    */
114   protected $transitions = [];
115
116   /**
117    * The workflow type plugin ID.
118    *
119    * @see \Drupal\workflows\WorkflowTypeManager
120    *
121    * @var string
122    */
123   protected $type;
124
125   /**
126    * The configuration for the workflow type plugin.
127    * @var array
128    */
129   protected $type_settings = [];
130
131   /**
132    * The workflow type plugin collection.
133    *
134    * @var \Drupal\Component\Plugin\LazyPluginCollection
135    */
136   protected $pluginCollection;
137
138   /**
139    * {@inheritdoc}
140    */
141   public function preSave(EntityStorageInterface $storage) {
142     $workflow_type = $this->getTypePlugin();
143     $missing_states = array_diff($workflow_type->getRequiredStates(), array_keys($this->getStates()));
144     if (!empty($missing_states)) {
145       throw new RequiredStateMissingException(sprintf("Workflow type '{$workflow_type->label()}' requires states with the ID '%s' in workflow '{$this->id()}'", implode("', '", $missing_states)));
146     }
147     parent::preSave($storage);
148   }
149
150   /**
151    * {@inheritdoc}
152    */
153   public function addState($state_id, $label) {
154     if (isset($this->states[$state_id])) {
155       throw new \InvalidArgumentException("The state '$state_id' already exists in workflow '{$this->id()}'");
156     }
157     if (preg_match('/[^a-z0-9_]+/', $state_id)) {
158       throw new \InvalidArgumentException("The state ID '$state_id' must contain only lowercase letters, numbers, and underscores");
159     }
160     $this->states[$state_id] = [
161       'label' => $label,
162       'weight' => $this->getNextWeight($this->states),
163     ];
164     ksort($this->states);
165     return $this;
166   }
167
168   /**
169    * {@inheritdoc}
170    */
171   public function hasState($state_id) {
172     return isset($this->states[$state_id]);
173   }
174
175   /**
176    * {@inheritdoc}
177    */
178   public function getStates($state_ids = NULL) {
179     if ($state_ids === NULL) {
180       $state_ids = array_keys($this->states);
181     }
182     /** @var \Drupal\workflows\StateInterface[] $states */
183     $states = array_combine($state_ids, array_map([$this, 'getState'], $state_ids));
184     if (count($states) > 1) {
185       // Sort states by weight and then label.
186       $weights = $labels = [];
187       foreach ($states as $id => $state) {
188         $weights[$id] = $state->weight();
189         $labels[$id] = $state->label();
190       }
191       array_multisort(
192         $weights, SORT_NUMERIC, SORT_ASC,
193         $labels, SORT_NATURAL, SORT_ASC
194       );
195       $states = array_replace($weights, $states);
196     }
197     return $states;
198   }
199
200   /**
201    * {@inheritdoc}
202    */
203   public function getState($state_id) {
204     if (!isset($this->states[$state_id])) {
205       throw new \InvalidArgumentException("The state '$state_id' does not exist in workflow '{$this->id()}'");
206     }
207     $state = new State(
208       $this,
209       $state_id,
210       $this->states[$state_id]['label'],
211       $this->states[$state_id]['weight']
212     );
213     return $this->getTypePlugin()->decorateState($state);
214   }
215
216   /**
217    * {@inheritdoc}
218    */
219   public function setStateLabel($state_id, $label) {
220     if (!isset($this->states[$state_id])) {
221       throw new \InvalidArgumentException("The state '$state_id' does not exist in workflow '{$this->id()}'");
222     }
223     $this->states[$state_id]['label'] = $label;
224     return $this;
225   }
226
227   /**
228    * {@inheritdoc}
229    */
230   public function setStateWeight($state_id, $weight) {
231     if (!isset($this->states[$state_id])) {
232       throw new \InvalidArgumentException("The state '$state_id' does not exist in workflow '{$this->id()}'");
233     }
234     $this->states[$state_id]['weight'] = $weight;
235     return $this;
236   }
237
238   /**
239    * {@inheritdoc}
240    */
241   public function deleteState($state_id) {
242     if (!isset($this->states[$state_id])) {
243       throw new \InvalidArgumentException("The state '$state_id' does not exist in workflow '{$this->id()}'");
244     }
245     if (count($this->states) === 1) {
246       throw new \InvalidArgumentException("The state '$state_id' can not be deleted from workflow '{$this->id()}' as it is the only state");
247     }
248
249     foreach ($this->transitions as $transition_id => $transition) {
250       $from_key = array_search($state_id, $transition['from'], TRUE);
251       if ($from_key !== FALSE) {
252         // Remove state from the from array.
253         unset($transition['from'][$from_key]);
254       }
255       if (empty($transition['from']) || $transition['to'] === $state_id) {
256         $this->deleteTransition($transition_id);
257       }
258       elseif ($from_key !== FALSE) {
259         $this->setTransitionFromStates($transition_id, $transition['from']);
260       }
261     }
262     unset($this->states[$state_id]);
263     $this->getTypePlugin()->deleteState($state_id);
264     return $this;
265   }
266
267   /**
268    * {@inheritdoc}
269    */
270   public function addTransition($transition_id, $label, array $from_state_ids, $to_state_id) {
271     if (isset($this->transitions[$transition_id])) {
272       throw new \InvalidArgumentException("The transition '$transition_id' already exists in workflow '{$this->id()}'");
273     }
274     if (preg_match('/[^a-z0-9_]+/', $transition_id)) {
275       throw new \InvalidArgumentException("The transition ID '$transition_id' must contain only lowercase letters, numbers, and underscores");
276     }
277
278     if (!$this->hasState($to_state_id)) {
279       throw new \InvalidArgumentException("The state '$to_state_id' does not exist in workflow '{$this->id()}'");
280     }
281     $this->transitions[$transition_id] = [
282       'label' => $label,
283       'from' => [],
284       'to' => $to_state_id,
285       // Always add to the end.
286       'weight' => $this->getNextWeight($this->transitions),
287     ];
288
289     try {
290       $this->setTransitionFromStates($transition_id, $from_state_ids);
291     }
292     catch (\InvalidArgumentException $e) {
293       unset($this->transitions[$transition_id]);
294       throw $e;
295     }
296
297     return $this;
298   }
299
300   /**
301    * {@inheritdoc}
302    */
303   public function getTransitions(array $transition_ids = NULL) {
304     if ($transition_ids === NULL) {
305       $transition_ids = array_keys($this->transitions);
306     }
307     /** @var \Drupal\workflows\TransitionInterface[] $transitions */
308     $transitions = array_combine($transition_ids, array_map([$this, 'getTransition'], $transition_ids));
309     if (count($transitions) > 1) {
310       // Sort transitions by weights and then labels.
311       $weights = $labels = [];
312       foreach ($transitions as $id => $transition) {
313         $weights[$id] = $transition->weight();
314         $labels[$id] = $transition->label();
315       }
316       array_multisort(
317         $weights, SORT_NUMERIC, SORT_ASC,
318         $labels, SORT_NATURAL, SORT_ASC
319       );
320       $transitions = array_replace($weights, $transitions);
321     }
322     return $transitions;
323   }
324
325   /**
326    * {@inheritdoc}
327    */
328   public function getTransition($transition_id) {
329     if (!isset($this->transitions[$transition_id])) {
330       throw new \InvalidArgumentException("The transition '$transition_id' does not exist in workflow '{$this->id()}'");
331     }
332     $transition = new Transition(
333       $this,
334       $transition_id,
335       $this->transitions[$transition_id]['label'],
336       $this->transitions[$transition_id]['from'],
337       $this->transitions[$transition_id]['to'],
338       $this->transitions[$transition_id]['weight']
339     );
340     return $this->getTypePlugin()->decorateTransition($transition);
341   }
342
343   /**
344    * {@inheritdoc}
345    */
346   public function hasTransition($transition_id) {
347     return isset($this->transitions[$transition_id]);
348   }
349
350   /**
351    * {@inheritdoc}
352    */
353   public function getTransitionsForState($state_id, $direction = 'from') {
354     $transition_ids = array_keys(array_filter($this->transitions, function ($transition) use ($state_id, $direction) {
355       return in_array($state_id, (array) $transition[$direction], TRUE);
356     }));
357     return $this->getTransitions($transition_ids);
358   }
359
360   /**
361    * {@inheritdoc}
362    */
363   public function getTransitionFromStateToState($from_state_id, $to_state_id) {
364     $transition_id = $this->getTransitionIdFromStateToState($from_state_id, $to_state_id);
365     if (empty($transition_id)) {
366       throw new \InvalidArgumentException("The transition from '$from_state_id' to '$to_state_id' does not exist in workflow '{$this->id()}'");
367     }
368     return $this->getTransition($transition_id);
369   }
370
371   /**
372    * {@inheritdoc}
373    */
374   public function hasTransitionFromStateToState($from_state_id, $to_state_id) {
375     return !empty($this->getTransitionIdFromStateToState($from_state_id, $to_state_id));
376   }
377
378   /**
379    * Gets the transition ID from state to state.
380    *
381    * @param string $from_state_id
382    *   The state ID to transition from.
383    * @param string $to_state_id
384    *   The state ID to transition to.
385    *
386    * @return string|null
387    *   The transition ID, or NULL if no transition exists.
388    */
389   protected function getTransitionIdFromStateToState($from_state_id, $to_state_id) {
390     foreach ($this->transitions as $transition_id => $transition) {
391       if (in_array($from_state_id, $transition['from'], TRUE) && $transition['to'] === $to_state_id) {
392         return $transition_id;
393       }
394     }
395     return NULL;
396   }
397
398   /**
399    * {@inheritdoc}
400    */
401   public function setTransitionLabel($transition_id, $label) {
402     if (isset($this->transitions[$transition_id])) {
403       $this->transitions[$transition_id]['label'] = $label;
404     }
405     else {
406       throw new \InvalidArgumentException("The transition '$transition_id' does not exist in workflow '{$this->id()}'");
407     }
408     return $this;
409   }
410
411   /**
412    * {@inheritdoc}
413    */
414   public function setTransitionWeight($transition_id, $weight) {
415     if (isset($this->transitions[$transition_id])) {
416       $this->transitions[$transition_id]['weight'] = $weight;
417     }
418     else {
419       throw new \InvalidArgumentException("The transition '$transition_id' does not exist in workflow '{$this->id()}'");
420     }
421     return $this;
422   }
423
424   /**
425    * {@inheritdoc}
426    */
427   public function setTransitionFromStates($transition_id, array $from_state_ids) {
428     if (!isset($this->transitions[$transition_id])) {
429       throw new \InvalidArgumentException("The transition '$transition_id' does not exist in workflow '{$this->id()}'");
430     }
431
432     // Ensure that the states exist.
433     foreach ($from_state_ids as $from_state_id) {
434       if (!$this->hasState($from_state_id)) {
435         throw new \InvalidArgumentException("The state '$from_state_id' does not exist in workflow '{$this->id()}'");
436       }
437       if ($this->hasTransitionFromStateToState($from_state_id, $this->transitions[$transition_id]['to'])) {
438         $transition = $this->getTransitionFromStateToState($from_state_id, $this->transitions[$transition_id]['to']);
439         if ($transition_id !== $transition->id()) {
440           throw new \InvalidArgumentException("The '{$transition->id()}' transition already allows '$from_state_id' to '{$this->transitions[$transition_id]['to']}' transitions in workflow '{$this->id()}'");
441         }
442       }
443     }
444
445     // Preserve the order of the state IDs in the from value and don't save any
446     // keys.
447     $from_state_ids = array_values($from_state_ids);
448     sort($from_state_ids);
449     $this->transitions[$transition_id]['from'] = $from_state_ids;
450     ksort($this->transitions);
451
452     return $this;
453   }
454
455   /**
456    * {@inheritdoc}
457    */
458   public function deleteTransition($transition_id) {
459     if (isset($this->transitions[$transition_id])) {
460       unset($this->transitions[$transition_id]);
461       $this->getTypePlugin()->deleteTransition($transition_id);
462     }
463     else {
464       throw new \InvalidArgumentException("The transition '$transition_id' does not exist in workflow '{$this->id()}'");
465     }
466     return $this;
467   }
468
469   /**
470    * {@inheritDoc}
471    */
472   public function getTypePlugin() {
473     return $this->getPluginCollection()->get($this->type);
474   }
475
476   /**
477    * {@inheritDoc}
478    */
479   public function getPluginCollections() {
480     return ['type_settings' => $this->getPluginCollection()];
481   }
482
483   /**
484    * Encapsulates the creation of the workflow's plugin collection.
485    *
486    * @return \Drupal\Core\Plugin\DefaultSingleLazyPluginCollection
487    *   The workflow's plugin collection.
488    */
489   protected function getPluginCollection() {
490     if (!$this->pluginCollection && $this->type) {
491       $this->pluginCollection = new DefaultSingleLazyPluginCollection(\Drupal::service('plugin.manager.workflows.type'), $this->type, $this->type_settings);
492     }
493     return $this->pluginCollection;
494   }
495
496   /**
497    * Loads all workflows of the provided type.
498    *
499    * @param string $type
500    *   The workflow type to load all workflows for.
501    *
502    * @return static[]
503    *   An array of workflow objects of the provided workflow type, indexed by
504    *   their IDs.
505    *
506    *  @see \Drupal\workflows\Annotation\WorkflowType
507    */
508   public static function loadMultipleByType($type) {
509     return self::loadMultiple(\Drupal::entityQuery('workflow')->condition('type', $type)->execute());
510   }
511
512   /**
513    * Gets the weight for a new state or transition.
514    *
515    * @param array $items
516    *   An array of states or transitions information where each item has a
517    *   'weight' key with a numeric value.
518    *
519    * @return int
520    *   The weight for a new item in the array so that it has the highest weight.
521    */
522   protected function getNextWeight(array $items) {
523     return array_reduce($items, function ($carry, $item) {
524       return max($carry, $item['weight'] + 1);
525     }, 0);
526   }
527
528   /**
529    * {@inheritdoc}
530    */
531   public function status() {
532     // In order for a workflow to be usable it must have at least one state.
533     return !empty($this->status) && !empty($this->states);
534   }
535
536   /**
537    * {@inheritdoc}
538    */
539   public function onDependencyRemoval(array $dependencies) {
540     $changed = $this->getTypePlugin()->onDependencyRemoval($dependencies);
541     // Ensure the parent method is called in order to process dependencies that
542     // affect third party settings.
543     return parent::onDependencyRemoval($dependencies) || $changed;
544   }
545
546 }