AbstractManager::getCurrentNodeTitle()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
nc 1
nop 0
dl 0
loc 6
rs 10
c 0
b 0
f 0
1
<?php
2
namespace Fwlib\Workflow;
3
4
use Fwlib\Workflow\Exception\InvalidActionException;
5
6
/**
7
 * Workflow manager
8
 *
9
 * Some method belongs/mapped to workflow model, put here for easy usage.
10
 *
11
 * @copyright   Copyright 2014-2015 Fwolf
12
 * @license     http://www.gnu.org/licenses/lgpl.html LGPL-3.0+
13
 */
14
abstract class AbstractManager implements ManagerInterface
15
{
16
    /**
17
     * Workflow end result code
18
     *
19
     * When workflow is ended, it can have a result code to present status
20
     * like approved or rejected or user canceled.
21
     */
22
    const RESULT_CODE_NOT_ENDED = 0;
23
    const RESULT_CODE_APPROVED = 1;
24
    const RESULT_CODE_REJECTED = -1;
25
    const RESULT_CODE_CANCELED = -2;
26
27
28
    /**
29
     * Storage of disabled actions
30
     *
31
     * {node, action: {}}
32
     *
33
     * @var array
34
     */
35
    protected $disabledActions = [];
36
37
    /**
38
     * Workflow model instance
39
     *
40
     * @var ModelInterface
41
     */
42
    protected $model = null;
43
44
    /**
45
     * Classname of workflow model
46
     *
47
     * When start a new workflow, this classname is used to create empty model
48
     * instance.
49
     *
50
     * @var string
51
     */
52
    protected $modelClass = ModelInterface::class;
53
54
    /**
55
     * Workflow nodes schema array
56
     *
57
     * Should at least have one start node and one end node.
58
     *
59
     * Action name should be unique in all nodes, same action may cause error,
60
     * and confusion for reading code, especially when add controller/view
61
     * action in view or template.
62
     *
63
     * Default value of resultCode is self::RESULT_CODE_NOT_ENDED if not set.
64
     * ResultCode should set only on action relate to end node. When leave end
65
     * node(rollback), resultCode is reset(param default value of move()), or
66
     * user can specify through action.  Set resultCode on other action is
67
     * useless.
68
     *
69
     * The end node should not have action 'next' point to itself, that action
70
     * will always trigger both commit() and rollback(). A check for this in
71
     * move() will throw Exception 'end twice'.
72
     *
73
     * @var array
74
     */
75
    protected $nodes = [
76
        'start' => [
77
            'title'     => 'Started',
78
            'actions'   => [
79
                'edit'      => [
80
                    'title' => 'Edit',
81
                    'next'  => 'start',
82
                ],
83
                'submit'    => [
84
                    'title' => 'Submit',
85
                    'next'  => 'end',
86
                    'resultCode'     => self::RESULT_CODE_APPROVED,
87
                ],
88
            ],
89
        ],
90
        'end'   => [
91
            'title'     => 'Ended',
92
        ],
93
    ];
94
95
    /**
96
     * Not available actions
97
     *
98
     * @var array   {action: {title, message or reason}}
99
     */
100
    protected $notAvailableActions = [];
101
102
    /**
103
     * Workflow result code title
104
     *
105
     * @var array
106
     */
107
    protected $resultCodeTitle = [
108
        self::RESULT_CODE_NOT_ENDED => 'Not Ended',
109
        self::RESULT_CODE_APPROVED  => 'Approved',
110
        self::RESULT_CODE_REJECTED  => 'Rejected',
111
        self::RESULT_CODE_CANCELED  => 'Canceled',
112
    ];
113
114
    /**
115
     * Title of workflow class
116
     *
117
     * Usually include the description of what this workflow will do.
118
     *
119
     * @return  string
120
     */
121
    protected $workflowTitle = 'Workflow Title';
122
123
124
    /**
125
     * Constructor
126
     *
127
     * @param   string  $uuid
128
     */
129
    public function __construct($uuid = '')
130
    {
131
        $this->load($uuid);
132
    }
133
134
135
    /**
136
     * Store change done in commit(), for rollback
137
     */
138
    protected function afterCommit()
139
    {
140
        // Dummy, do nothing
141
    }
142
143
144
    /**
145
     * Prepare to record changes in commit(), for rollback
146
     */
147
    protected function beforeCommit()
148
    {
149
        // Dummy, do nothing
150
    }
151
152
153
    /**
154
     * Process after workflow end and resultCode is approved
155
     *
156
     * In common, this method should write $contents to entity storage.
157
     *
158
     * If use DbDiff to store entity db change, there will have an extra
159
     * UPDATE to db (the former one is save()), by this cost, the workflow got
160
     * possibility to rollback from end node.
161
     *
162
     * Workflow may have no rollback ability, but should commit something, so
163
     * commit() is abstract and must fill by child class, as rollback() is
164
     * default empty.
165
     */
166
    abstract protected function commit();
167
168
169
    /**
170
     * {@inheritdoc}
171
     */
172
    public function disableAction($action)
173
    {
174
        foreach ($this->nodes as $nodeIndex => &$node) {
175
            if (isset($node['actions'][$action])) {
176
                $this->disabledActions[$action] = [
177
                    'node'   => $nodeIndex,
178
                    'action' => $node['actions'][$action],
179
                ];
180
181
                unset($node['actions'][$action]);
182
183
                break;
184
            }
185
        }
186
        unset($node);
187
188
        return $this;
189
    }
190
191
192
    /**
193
     * {@inheritdoc}
194
     */
195 View Code Duplication
    public function disableActions(array $actions)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
196
    {
197
        foreach ($this->nodes as $nodeIndex => &$node) {
198
            // Some node like end has no actions
199
            if (!array_key_exists('actions', $node)) {
200
                continue;
201
            }
202
203
            foreach ($node['actions'] as $action => $value) {
204
                if (in_array($action, $actions)) {
205
                    $this->disabledActions[$action] = [
206
                        'node'   => $nodeIndex,
207
                        'action' => $node['actions'][$action],
208
                    ];
209
210
                    unset($node['actions'][$action]);
211
                }
212
            }
213
        }
214
        unset($node);
215
216
        return $this;
217
    }
218
219
220
    /**
221
     * {@inheritdoc}
222
     */
223
    public function enableAction($action)
224
    {
225
        if (isset($this->disabledActions[$action])) {
226
            $this->nodes[$this->disabledActions[$action]['node']]['actions']
227
                [$action] = $this->disabledActions[$action]['action'];
228
229
            unset($this->disabledActions[$action]);
230
        }
231
232
        return $this;
233
    }
234
235
236
    /**
237
     * {@inheritdoc}
238
     */
239
    public function enableActions(array $actions)
240
    {
241
        foreach ($actions as $action) {
242
            $this->enableAction($action);
243
        }
244
245
        return $this;
246
    }
247
248
249
    /**
250
     * {@inheritdoc}
251
     *
252
     * User can define customized executeAction() method to do extra job like
253
     * generate profile code. this method should not include move() anymore,
254
     * nor should this method set resultCode, that should be set as action
255
     * property in $nodes define array.
256
     */
257
    public function execute($action)
258
    {
259
        // Contents are changed by updateContents() from outside, before this.
260
261
        $method = 'execute' . ucfirst($action);
262
        if (method_exists($this, $method)) {
263
            $this->$method();
264
        }
265
266
        // Check action available by updated state, not original
267
        if (!$this->isActionAvailable($action)) {
268
            throw new InvalidActionException(
269
                "Invalid or not allowed action $action"
270
            );
271
        }
272
273
        $actionArray = $this->nodes[$this->model->getCurrentNode()]
274
            ['actions'][$action];
275
        $this->move(
276
            $action,
277
            $actionArray['next'],
278
            (isset($actionArray['resultCode']) ? $actionArray['resultCode']
279
                : static::RESULT_CODE_NOT_ENDED)
280
        );
281
282
        return $this;
283
    }
284
285
286
    /**
287
     * Find links/relations the workflow instance have
288
     *
289
     * @return  array|null  Return null to disable save of link
290
     */
291
    protected function findLinks()
292
    {
293
        // Dummy, return null
294
        return null;
295
    }
296
297
298
    /**
299
     * Get title of action, will search action in nodes
300
     *
301
     * @param   string  $action
302
     * @return  string
303
     * @throws  \Exception  If nodes use invalid action
304
     */
305
    public function getActionTitle($action)
306
    {
307
        foreach ($this->nodes as &$node) {
308
            if (isset($node['actions'][$action])) {
309
                return $node['actions'][$action]['title'];
310
            }
311
        }
312
        unset($node);
313
314
        throw new \Exception("Get title from invalid action $action");
315
    }
316
317
318
    /**
319
     * {@inheritdoc}
320
     */
321
    public function getAvailableActions()
322
    {
323
        $availableActions = [];
324
        if (!isset($this->nodes[$this->model->getCurrentNode()]['actions'])) {
325
            return $availableActions;
326
        }
327
328
        $actions = $this->nodes[$this->model->getCurrentNode()]['actions'];
329
        foreach ($actions as $action => $actionArray) {
330
            if ($this->isActionAvailable($action)) {
331
                $availableActions[$action] = $actionArray;
332
            }
333
        }
334
335
        return $availableActions;
336
    }
337
338
339
    /**
340
     * Getter of single content
341
     *
342
     * @param   string  $key
343
     * @return  array
344
     */
345
    public function getContent($key)
346
    {
347
        return $this->model->getContent($key);
348
    }
349
350
351
    /**
352
     * Getter of whole content array
353
     *
354
     * @return  array
355
     */
356
    public function getContents()
357
    {
358
        return $this->model->getContents();
359
    }
360
361
362
    /**
363
     * Getter of current node
364
     *
365
     * @return  string
366
     */
367
    public function getCurrentNode()
368
    {
369
        return $this->model->getCurrentNode();
370
    }
371
372
373
    /**
374
     * Getter of current node title
375
     *
376
     * @return  string
377
     */
378
    public function getCurrentNodeTitle()
379
    {
380
        $node = $this->model->getCurrentNode();
381
382
        return $this->nodes[$node]['title'];
383
    }
384
385
386
    /**
387
     * {@inheritdoc}
388
     */
389
    public function getModel()
390
    {
391
        return $this->model;
392
    }
393
394
395
    /**
396
     * {@inheritdoc}
397
     */
398
    public function getModelClass()
399
    {
400
        return $this->modelClass;
401
    }
402
403
404
    /**
405
     * Getter of $notAvailableActions
406
     *
407
     * @return  array
408
     */
409
    public function getNotAvailableActions()
410
    {
411
        return $this->notAvailableActions;
412
    }
413
414
415
    /**
416
     * Getter of result code
417
     *
418
     * @return  int
419
     */
420
    public function getResultCode()
421
    {
422
        return $this->model->getResultCode();
423
    }
424
425
426
    /**
427
     * Get title of result code
428
     *
429
     * If $resultCode is null, will get result code of $model, otherwise will
430
     * return title for given result code.
431
     *
432
     * @param   int     $resultCode
433
     * @return  string
434
     */
435
    public function getResultCodeTitle($resultCode = null)
436
    {
437
        if (is_null($resultCode)) {
438
            $resultCode = $this->model->getResultCode();
439
        }
440
441
        return $this->resultCodeTitle[$resultCode];
442
    }
443
444
445
    /**
446
     * Getter of title
447
     *
448
     * @return  string
449
     */
450
    public function getTitle()
451
    {
452
        return $this->model->getTitle();
453
    }
454
455
456
    /**
457
     * Getter of uuid
458
     *
459
     * @return  string
460
     */
461
    public function getUuid()
462
    {
463
        return $this->model->getUuid();
464
    }
465
466
467
    /**
468
     * {@inheritdoc}
469
     */
470
    public function getWorkflowTitle()
471
    {
472
        return $this->workflowTitle;
473
    }
474
475
476
    /**
477
     * Initialize an empty workflow instance
478
     */
479
    protected function initialize()
480
    {
481
        // Prepare content or assign default value to model
482
        $this->model->setResultCode(static::RESULT_CODE_NOT_ENDED);
483
    }
484
485
486
    /**
487
     * Is an action available ?
488
     *
489
     * Only actions of current node can be available, and default available.
490
     *
491
     * User should not extend this method directly, instead, user can create
492
     * customize check method for any single $action, named as
493
     * isAction[ActionName]Available(). These method should explicit return
494
     * true to pass available check, other return value will be consider as
495
     * check fail, and will be saved as fail reason/message in property
496
     * $notAvailableActions. This property can be used to show user why these
497
     * action can't execute.
498
     *
499
     * This is more flexible than complicated condition string.
500
     *
501
     * @param   string  $action
502
     * @return  boolean
503
     */
504
    public function isActionAvailable($action)
505
    {
506
        if (!isset(
507
            $this->nodes[$this->model->getCurrentNode()]['actions'][$action]
508
        )) {
509
            return false;
510
        }
511
512
        // Use action specified check method
513
        $method = "isAction" . ucfirst($action) . "Available";
514
        if (method_exists($this, $method)) {
515
            $checkResult = $this->$method();
516
517
            if (true !== $checkResult) {
518
                $this->notAvailableActions[$action] = [
519
                    'title'   => $this->nodes[$this->model->getCurrentNode()]
520
                        ['actions'][$action]['title'],
521
                    'message' => (string)$checkResult,
522
                ];
523
                return false;
524
            }
525
        }
526
527
        unset($this->notAvailableActions[$action]);
528
        return true;
529
    }
530
531
532
    /**
533
     * {@inheritdoc}
534
     */
535
    public function isApproved()
536
    {
537
        return static::RESULT_CODE_APPROVED == $this->model->getResultCode();
538
    }
539
540
541
    /**
542
     * {@inheritdoc}
543
     */
544
    public function isEnded()
545
    {
546
        return 'end' == $this->model->getCurrentNode();
547
    }
548
549
550
551
    /**
552
     * {@inheritdoc}
553
     */
554 View Code Duplication
    public function limitActions(array $actions)
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
555
    {
556
        foreach ($this->nodes as $nodeIndex => &$node) {
557
            // Some node like end has no actions
558
            if (!array_key_exists('actions', $node)) {
559
                continue;
560
            }
561
562
            foreach ($node['actions'] as $action => $value) {
563
                if (!in_array($action, $actions)) {
564
                    $this->disabledActions[$action] = [
565
                        'node'   => $nodeIndex,
566
                        'action' => $node['actions'][$action],
567
                    ];
568
569
                    unset($node['actions'][$action]);
570
                }
571
            }
572
        }
573
        unset($node);
574
575
        return $this;
576
    }
577
578
579
    /**
580
     * {@inheritdoc}
581
     */
582
    public function load($uuid)
583
    {
584
        $this->model = new $this->modelClass($uuid);
585
586
        // Check and initialize model instance
587
        if (empty($uuid)) {
588
            $this->initialize();
589
        }
590
591
        return $this;
592
    }
593
594
595
    /**
596
     * Move workflow to another node
597
     *
598
     * After workflow move to end node and is approved, the method commit()
599
     * will be called, the reverse operate is rollback(), called when node
600
     * leave from end. The end result rejected or canceled has no alike
601
     * mechanism, because in common nothing need to do, although child class
602
     * can extend this method to add that.
603
     *
604
     * @param   string  $action     Moved by action
605
     * @param   string  $node
606
     * @param   int     $resultCode Should set when to or from end node.
607
     * @return  AbstractManager
608
     * @throws  \Exception  If 2 user end a workflow at same time.
609
     */
610
    protected function move(
611
        $action,
612
        $node,
613
        $resultCode = self::RESULT_CODE_NOT_ENDED
614
    ) {
615
        $prevIsApproved = $this->isApproved();
616
        $prevIsEnd = $this->isEnded();
617
        $prevNode = $this->model->getCurrentNode();
618
619
        $this->model->setCurrentNode($node);
620
        $this->model->setResultCode($resultCode);
621
        $currentIsApproved = $this->isApproved();
622
        $currentIsEnd = $this->isEnded();
623
624
        // Safe check for concurrence, if two user end a workflow at same
625
        // time, the later one will throw error.
626
        if ($currentIsEnd && $prevIsEnd) {
627
            throw new \Exception(
628
                'Workflow can\'t end twice'
629
            );
630
        }
631
632
        $this->save();
633
634
        if ($prevNode != $node) {
635
            $this->saveLog($action, $prevNode, $node);
636
        }
637
638
        if ($currentIsEnd && $currentIsApproved) {
639
            $this->beforeCommit();
640
            $this->commit();
641
            $this->afterCommit();
642
643
        } elseif ($prevIsEnd && $prevIsApproved) {
644
            $this->rollback();
645
        }
646
647
        return $this;
648
    }
649
650
651
    /**
652
     * Rollback data written by commit()
653
     */
654
    protected function rollback()
655
    {
656
        // Dummy, do nothing
657
    }
658
659
660
    /**
661
     * Save workflow
662
     *
663
     * For new created workflow instance, save() method should generate and
664
     * update $uuid property.
665
     */
666
    protected function save()
667
    {
668
        $this->model->save();
669
670
        $links = $this->findLinks();
671
        $this->model->syncLinks($links);
0 ignored issues
show
Bug introduced by
It seems like $links defined by $this->findLinks() on line 670 can also be of type null; however, Fwlib\Workflow\ModelInterface::syncLinks() does only seem to accept array, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
672
    }
673
674
675
    /**
676
     * Save workflow change log
677
     *
678
     * Log is only saved when node change.
679
     *
680
     * @param   string  $action
681
     * @param   string  $prevNode
682
     * @param   string  $nextNode
683
     */
684
    protected function saveLog($action, $prevNode, $nextNode)
685
    {
686
        $actionTitle = $this->nodes[$prevNode]['actions'][$action]['title'];
687
688
        $this->model->addLog($action, $actionTitle, $prevNode, $nextNode);
689
    }
690
691
692
    /**
693
     * {@inheritdoc}
694
     */
695
    public function setModel(ModelInterface $model)
696
    {
697
        $this->model = $model;
698
699
        return $this;
700
    }
701
702
703
    /**
704
     * Change action title
705
     *
706
     * Sometimes action title can't fit both detail and review view mode. For
707
     * example, an action with title 'Save' works fine in review mode. But in
708
     * detail mode, the title 'Go to Edit' is more suitable.
709
     *
710
     * Note: choose a appropriate title is better than do modify like this.
711
     *
712
     * @param   string  $node
713
     * @param   string  $action
714
     * @param   string  $title
715
     * @return  AbstractManager
716
     */
717
    public function setNodeActionTitle($node, $action, $title)
718
    {
719
        $this->nodes[$node]['actions'][$action]['title'] = $title;
720
721
        return $this;
722
    }
723
724
725
    /**
726
     * Update $contents
727
     *
728
     * Eg: Assign filtered $_POST data from View.
729
     *
730
     * @param   array   $contents
731
     * @return  AbstractManager
732
     */
733
    public function updateContents(array $contents = null)
734
    {
735
        if (!empty($contents)) {
736
            $this->model->setContents(
737
                array_merge($this->model->getContents(), $contents)
738
            );
739
        }
740
741
        // Even request may contain no data, there may have preset readonly
742
        // information in contents, and model need to be updated.
743
        $this->updateModelByContents();
744
745
        return $this;
746
    }
747
748
749
    /**
750
     * Update model information by contents
751
     *
752
     * Model property that not depends on contents should set in initialize()
753
     * or executeAction() method.
754
     */
755
    protected function updateModelByContents()
756
    {
757
        // Dummy
758
    }
759
}
760