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