1<?php
2/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
3
4namespace Icinga\Web;
5
6use LogicException;
7use InvalidArgumentException;
8use Icinga\Web\Session\SessionNamespace;
9use Icinga\Web\Form\Decorator\ElementDoubler;
10
11/**
12 * Container and controller for form based wizards
13 */
14class Wizard
15{
16    /**
17     * An integer describing the wizard's forward direction
18     */
19    const FORWARD = 0;
20
21    /**
22     * An integer describing the wizard's backward direction
23     */
24    const BACKWARD = 1;
25
26    /**
27     * An integer describing that the wizard does not change its position
28     */
29    const NO_CHANGE = 2;
30
31    /**
32     * The name of the button to advance the wizard's position
33     */
34    const BTN_NEXT = 'btn_next';
35
36    /**
37     * The name of the button to rewind the wizard's position
38     */
39    const BTN_PREV = 'btn_prev';
40
41    /**
42     * The name and id of the element for showing the user an activity indicator when advancing the wizard
43     */
44    const PROGRESS_ELEMENT = 'wizard_progress';
45
46    /**
47     * This wizard's parent
48     *
49     * @var Wizard
50     */
51    protected $parent;
52
53    /**
54     * The name of the wizard's current page
55     *
56     * @var string
57     */
58    protected $currentPage;
59
60    /**
61     * The pages being part of this wizard
62     *
63     * @var array
64     */
65    protected $pages = array();
66
67    /**
68     * Initialize a new wizard
69     */
70    public function __construct()
71    {
72        $this->init();
73    }
74
75    /**
76     * Run additional initialization routines
77     *
78     * Should be implemented by subclasses to add pages to the wizard.
79     */
80    protected function init()
81    {
82    }
83
84    /**
85     * Return this wizard's parent or null in case it has none
86     *
87     * @return  Wizard|null
88     */
89    public function getParent()
90    {
91        return $this->parent;
92    }
93
94    /**
95     * Set this wizard's parent
96     *
97     * @param   Wizard  $wizard     The parent wizard
98     *
99     * @return  $this
100     */
101    public function setParent(Wizard $wizard)
102    {
103        $this->parent = $wizard;
104        return $this;
105    }
106
107    /**
108     * Return the pages being part of this wizard
109     *
110     * In case this is a nested wizard a flattened array of all contained pages is returned.
111     *
112     * @return  array
113     */
114    public function getPages()
115    {
116        $pages = array();
117        foreach ($this->pages as $page) {
118            if ($page instanceof self) {
119                $pages = array_merge($pages, $page->getPages());
120            } else {
121                $pages[] = $page;
122            }
123        }
124
125        return $pages;
126    }
127
128    /**
129     * Return the page with the given name
130     *
131     * Note that it's also possible to retrieve a nested wizard's page by using this method.
132     *
133     * @param   string      $name   The name of the page to return
134     *
135     * @return  null|Form           The page or null in case there is no page with the given name
136     */
137    public function getPage($name)
138    {
139        foreach ($this->getPages() as $page) {
140            if ($name === $page->getName()) {
141                return $page;
142            }
143        }
144    }
145
146    /**
147     * Add a new page or wizard to this wizard
148     *
149     * @param   Form|Wizard     $page   The page or wizard to add to the wizard
150     *
151     * @return  $this
152     */
153    public function addPage($page)
154    {
155        if (! $page instanceof Form && ! $page instanceof self) {
156            throw InvalidArgumentException(
157                'The $page argument must be an instance of Icinga\Web\Form '
158                . 'or Icinga\Web\Wizard but is of type: ' . get_class($page)
159            );
160        } elseif ($page instanceof self) {
161            $page->setParent($this);
162        }
163
164        $this->pages[] = $page;
165        return $this;
166    }
167
168    /**
169     * Add multiple pages or wizards to this wizard
170     *
171     * @param   array   $pages      The pages or wizards to add to the wizard
172     *
173     * @return  $this
174     */
175    public function addPages(array $pages)
176    {
177        foreach ($pages as $page) {
178            $this->addPage($page);
179        }
180
181        return $this;
182    }
183
184    /**
185     * Assert that this wizard has any pages
186     *
187     * @throws  LogicException      In case this wizard has no pages
188     */
189    protected function assertHasPages()
190    {
191        $pages = $this->getPages();
192        if (count($pages) < 2) {
193            throw new LogicException("Although Chuck Norris can advance a wizard with less than two pages, you can't.");
194        }
195    }
196
197    /**
198     * Return the current page of this wizard
199     *
200     * @return  Form
201     *
202     * @throws  LogicException      In case the name of the current page currently being set is invalid
203     */
204    public function getCurrentPage()
205    {
206        if ($this->parent) {
207            return $this->parent->getCurrentPage();
208        }
209
210        if ($this->currentPage === null) {
211            $this->assertHasPages();
212            $pages = $this->getPages();
213            $this->currentPage = $this->getSession()->get('current_page', $pages[0]->getName());
214        }
215
216        if (($page = $this->getPage($this->currentPage)) === null) {
217            throw new LogicException(sprintf('No page found with name "%s"', $this->currentPage));
218        }
219
220        return $page;
221    }
222
223    /**
224     * Set the current page of this wizard
225     *
226     * @param   Form    $page   The page to set as current page
227     *
228     * @return  $this
229     */
230    public function setCurrentPage(Form $page)
231    {
232        $this->currentPage = $page->getName();
233        $this->getSession()->set('current_page', $this->currentPage);
234        return $this;
235    }
236
237    /**
238     * Setup the given page that is either going to be displayed or validated
239     *
240     * Implement this method in a subclass to populate default values and/or other data required to process the form.
241     *
242     * @param   Form        $page       The page to setup
243     * @param   Request     $request    The current request
244     */
245    public function setupPage(Form $page, Request $request)
246    {
247    }
248
249    /**
250     * Process the given request using this wizard
251     *
252     * Validate the request data using the current page, update the wizard's
253     * position and redirect to the page's redirect url upon success.
254     *
255     * @param   Request     $request    The request to be processed
256     *
257     * @return  Request                 The request supposed to be processed
258     */
259    public function handleRequest(Request $request = null)
260    {
261        $page = $this->getCurrentPage();
262
263        if (($wizard = $this->findWizard($page)) !== null) {
264            return $wizard->handleRequest($request);
265        }
266
267        if ($request === null) {
268            $request = $page->getRequest();
269        }
270
271        $this->setupPage($page, $request);
272        $requestData = $this->getRequestData($page, $request);
273        if ($page->wasSent($requestData)) {
274            if (($requestedPage = $this->getRequestedPage($requestData)) !== null) {
275                $isValid = false;
276                $direction = $this->getDirection($request);
277                if ($direction === static::FORWARD && $page->isValid($requestData)) {
278                    $isValid = true;
279                    if ($this->isLastPage($page)) {
280                        $this->setIsFinished();
281                    }
282                } elseif ($direction === static::BACKWARD) {
283                    $page->populate($requestData);
284                    $isValid = true;
285                }
286
287                if ($isValid) {
288                    $pageData = & $this->getPageData();
289                    $pageData[$page->getName()] = $page->getValues();
290                    $this->setCurrentPage($this->getNewPage($requestedPage, $page));
291                    $page->getResponse()->redirectAndExit($page->getRedirectUrl());
292                }
293            } elseif ($page->getValidatePartial()) {
294                $page->isValidPartial($requestData);
295            } else {
296                $page->populate($requestData);
297            }
298        } elseif (($pageData = $this->getPageData($page->getName())) !== null) {
299            $page->populate($pageData);
300        }
301
302        return $request;
303    }
304
305    /**
306     * Return the wizard for the given page or null if its not part of a wizard
307     *
308     * @param   Form    $page   The page to return its wizard for
309     *
310     * @return  Wizard|null
311     */
312    protected function findWizard(Form $page)
313    {
314        foreach ($this->getWizards() as $wizard) {
315            if ($wizard->getPage($page->getName()) === $page) {
316                return $wizard;
317            }
318        }
319    }
320
321    /**
322     * Return this wizard's child wizards
323     *
324     * @return  array
325     */
326    protected function getWizards()
327    {
328        $wizards = array();
329        foreach ($this->pages as $pageOrWizard) {
330            if ($pageOrWizard instanceof self) {
331                $wizards[] = $pageOrWizard;
332            }
333        }
334
335        return $wizards;
336    }
337
338    /**
339     * Return the request data based on given form's request method
340     *
341     * @param   Form        $page       The page to fetch the data for
342     * @param   Request     $request    The request to fetch the data from
343     *
344     * @return  array
345     */
346    protected function getRequestData(Form $page, Request $request)
347    {
348        if (strtolower($request->getMethod()) === $page->getMethod()) {
349            return $request->{'get' . ($request->isPost() ? 'Post' : 'Query')}();
350        }
351
352        return array();
353    }
354
355    /**
356     * Return the name of the requested page
357     *
358     * @param   array   $requestData    The request's data
359     *
360     * @return  null|string             The name of the requested page or null in case no page has been requested
361     */
362    protected function getRequestedPage(array $requestData)
363    {
364        if ($this->parent) {
365            return $this->parent->getRequestedPage($requestData);
366        }
367
368        if (isset($requestData[static::BTN_NEXT])) {
369            return $requestData[static::BTN_NEXT];
370        } elseif (isset($requestData[static::BTN_PREV])) {
371            return $requestData[static::BTN_PREV];
372        }
373    }
374
375    /**
376     * Return the direction of this wizard using the given request
377     *
378     * @param   Request     $request    The request to use
379     *
380     * @return  int                     The direction @see Wizard::FORWARD @see Wizard::BACKWARD @see Wizard::NO_CHANGE
381     */
382    protected function getDirection(Request $request = null)
383    {
384        if ($this->parent) {
385            return $this->parent->getDirection($request);
386        }
387
388        $currentPage = $this->getCurrentPage();
389
390        if ($request === null) {
391            $request = $currentPage->getRequest();
392        }
393
394        $requestData = $this->getRequestData($currentPage, $request);
395        if (isset($requestData[static::BTN_NEXT])) {
396            return static::FORWARD;
397        } elseif (isset($requestData[static::BTN_PREV])) {
398            return static::BACKWARD;
399        }
400
401        return static::NO_CHANGE;
402    }
403
404    /**
405     * Return the new page to set as current page
406     *
407     * Permission is checked by verifying that the requested page or its previous page has page data available.
408     * The requested page is automatically permitted without any checks if the origin page is its previous
409     * page or one that occurs later in order.
410     *
411     * @param   string  $requestedPage      The name of the requested page
412     * @param   Form    $originPage         The origin page
413     *
414     * @return  Form                        The new page
415     *
416     * @throws  InvalidArgumentException    In case the requested page does not exist or is not permitted yet
417     */
418    protected function getNewPage($requestedPage, Form $originPage)
419    {
420        if ($this->parent) {
421            return $this->parent->getNewPage($requestedPage, $originPage);
422        }
423
424        if (($page = $this->getPage($requestedPage)) !== null) {
425            $permitted = true;
426
427            $pages = $this->getPages();
428            if (! $this->hasPageData($requestedPage) && ($index = array_search($page, $pages, true)) > 0) {
429                $previousPage = $pages[$index - 1];
430                if ($originPage === null || ($previousPage->getName() !== $originPage->getName()
431                    && array_search($originPage, $pages, true) < $index)) {
432                    $permitted = $this->hasPageData($previousPage->getName());
433                }
434            }
435
436            if ($permitted) {
437                return $page;
438            }
439        }
440
441        throw new InvalidArgumentException(
442            sprintf('"%s" is either an unknown page or one you are not permitted to view', $requestedPage)
443        );
444    }
445
446    /**
447     * Return the next or previous page based on the given one
448     *
449     * @param   Form    $page   The page to skip
450     *
451     * @return  Form
452     */
453    protected function skipPage(Form $page)
454    {
455        if ($this->parent) {
456            return $this->parent->skipPage($page);
457        }
458
459        if ($this->hasPageData($page->getName())) {
460            $pageData = & $this->getPageData();
461            unset($pageData[$page->getName()]);
462        }
463
464        $pages = $this->getPages();
465        if ($this->getDirection() === static::FORWARD) {
466            $nextPage = $pages[array_search($page, $pages, true) + 1];
467            $newPage = $this->getNewPage($nextPage->getName(), $page);
468        } else { // $this->getDirection() === static::BACKWARD
469            $previousPage = $pages[array_search($page, $pages, true) - 1];
470            $newPage = $this->getNewPage($previousPage->getName(), $page);
471        }
472
473        return $newPage;
474    }
475
476    /**
477     * Return whether the given page is this wizard's last page
478     *
479     * @param   Form    $page   The page to check
480     *
481     * @return  bool
482     */
483    protected function isLastPage(Form $page)
484    {
485        if ($this->parent) {
486            return $this->parent->isLastPage($page);
487        }
488
489        $pages = $this->getPages();
490        return $page->getName() === end($pages)->getName();
491    }
492
493    /**
494     * Return whether all of this wizard's pages were visited by the user
495     *
496     * The base implementation just verifies that the very last page has page data available.
497     *
498     * @return  bool
499     */
500    public function isComplete()
501    {
502        $pages = $this->getPages();
503        return $this->hasPageData($pages[count($pages) - 1]->getName());
504    }
505
506    /**
507     * Set whether this wizard has been completed
508     *
509     * @param   bool    $state      Whether this wizard has been completed
510     *
511     * @return  $this
512     */
513    public function setIsFinished($state = true)
514    {
515        $this->getSession()->set('isFinished', $state);
516        return $this;
517    }
518
519    /**
520     * Return whether this wizard has been completed
521     *
522     * @return  bool
523     */
524    public function isFinished()
525    {
526        return $this->getSession()->get('isFinished', false);
527    }
528
529    /**
530     * Return the overall page data or one for a particular page
531     *
532     * Note that this method returns by reference so in order to update the
533     * returned array set this method's return value also by reference.
534     *
535     * @param   string  $pageName   The page for which to return the data
536     *
537     * @return  array
538     */
539    public function & getPageData($pageName = null)
540    {
541        $session = $this->getSession();
542
543        if (false === isset($session->page_data)) {
544            $session->page_data = array();
545        }
546
547        $pageData = & $session->getByRef('page_data');
548        if ($pageName !== null) {
549            $data = null;
550            if (isset($pageData[$pageName])) {
551                $data = & $pageData[$pageName];
552            }
553
554            return $data;
555        }
556
557        return $pageData;
558    }
559
560    /**
561     * Return whether there is any data for the given page
562     *
563     * @param   string  $pageName   The name of the page to check
564     *
565     * @return  bool
566     */
567    public function hasPageData($pageName)
568    {
569        return $this->getPageData($pageName) !== null;
570    }
571
572    /**
573     * Return a session to be used by this wizard
574     *
575     * @return  SessionNamespace
576     */
577    public function getSession()
578    {
579        if ($this->parent) {
580            return $this->parent->getSession();
581        }
582
583        return Session::getSession()->getNamespace(get_class($this));
584    }
585
586    /**
587     * Clear the session being used by this wizard
588     */
589    public function clearSession()
590    {
591        $this->getSession()->clear();
592    }
593
594    /**
595     * Add buttons to the given page based on its position in the page-chain
596     *
597     * @param   Form    $page   The page to add the buttons to
598     */
599    protected function addButtons(Form $page)
600    {
601        $pages = $this->getPages();
602        $index = array_search($page, $pages, true);
603        if ($index === 0) {
604            $page->addElement(
605                'button',
606                static::BTN_NEXT,
607                array(
608                    'class'         => 'control-button btn-primary',
609                    'type'          => 'submit',
610                    'value'         => $pages[1]->getName(),
611                    'label'         => t('Next'),
612                    'decorators'    => array('ViewHelper', 'Spinner')
613                )
614            );
615        } elseif ($index < count($pages) - 1) {
616            $page->addElement(
617                'button',
618                static::BTN_PREV,
619                array(
620                    'class'             => 'control-button',
621                    'type'              => 'submit',
622                    'value'             => $pages[$index - 1]->getName(),
623                    'label'             => t('Back'),
624                    'decorators'        => array('ViewHelper'),
625                    'formnovalidate'    => 'formnovalidate'
626                )
627            );
628            $page->addElement(
629                'button',
630                static::BTN_NEXT,
631                array(
632                    'class'         => 'control-button btn-primary',
633                    'type'          => 'submit',
634                    'value'         => $pages[$index + 1]->getName(),
635                    'label'         => t('Next'),
636                    'decorators'    => array('ViewHelper')
637                )
638            );
639        } else {
640            $page->addElement(
641                'button',
642                static::BTN_PREV,
643                array(
644                    'class'             => 'control-button',
645                    'type'              => 'submit',
646                    'value'             => $pages[$index - 1]->getName(),
647                    'label'             => t('Back'),
648                    'decorators'        => array('ViewHelper'),
649                    'formnovalidate'    => 'formnovalidate'
650                )
651            );
652            $page->addElement(
653                'button',
654                static::BTN_NEXT,
655                array(
656                    'class'         => 'control-button btn-primary',
657                    'type'          => 'submit',
658                    'value'         => $page->getName(),
659                    'label'         => t('Finish'),
660                    'decorators'    => array('ViewHelper')
661                )
662            );
663        }
664
665        $page->setAttrib('data-progress-element', static::PROGRESS_ELEMENT);
666        $page->addElement(
667            'note',
668            static::PROGRESS_ELEMENT,
669            array(
670                'order'         => 99, // Ensures that it's shown on the right even if a sub-class adds another button
671                'decorators'    => array(
672                    'ViewHelper',
673                    array('Spinner', array('id' => static::PROGRESS_ELEMENT))
674                )
675            )
676        );
677
678        $page->addDisplayGroup(
679            array(static::BTN_PREV, static::BTN_NEXT, static::PROGRESS_ELEMENT),
680            'buttons',
681            array(
682                'decorators' => array(
683                    'FormElements',
684                    new ElementDoubler(array(
685                        'double'        => static::BTN_NEXT,
686                        'condition'     => static::BTN_PREV,
687                        'placement'     => ElementDoubler::PREPEND,
688                        'attributes'    => array('tabindex' => -1, 'class' => 'double')
689                    )),
690                    array('HtmlTag', array('tag' => 'div', 'class' => 'buttons'))
691                )
692            )
693        );
694    }
695
696    /**
697     * Return the current page of this wizard with appropriate buttons being added
698     *
699     * @return  Form
700     */
701    public function getForm()
702    {
703        $form = $this->getCurrentPage();
704        $form->create(); // Make sure that buttons are displayed at the very bottom
705        $this->addButtons($form);
706        return $form;
707    }
708
709    /**
710     * Return the current page of this wizard rendered as HTML
711     *
712     * @return  string
713     */
714    public function __toString()
715    {
716        return (string) $this->getForm();
717    }
718}
719