PageRenderTime 69ms CodeModel.GetById 11ms RepoModel.GetById 0ms app.codeStats 0ms

/Core/Libs/Controller/Component/WizardComponent.php

http://github.com/infinitas/infinitas
PHP | 515 lines | 231 code | 78 blank | 206 comment | 62 complexity | 4b89f12c9af4bbd6a1850db1a4bd106d MD5 | raw file
  1. <?php
  2. /**
  3. * Wizard component by jaredhoyt.
  4. *
  5. * Handles multi-step form navigation, data persistence, validation callbacks, and plot-branching navigation.
  6. *
  7. * PHP versions 4 and 5
  8. *
  9. * Comments and bug reports welcome at jaredhoyt AT gmail DOT com
  10. *
  11. *
  12. *
  13. * @writtenby jaredhoyt
  14. * @license http://www.opensource.org/licenses/mit-license.php The MIT License
  15. */
  16. App::uses('InfinitasComponent', 'Libs.Controller/Component');
  17. class WizardComponent extends InfinitasComponent {
  18. /**
  19. * The Component will redirect to the "expected step" after a step has been successfully
  20. * completed if autoAdvance is true. If false, the Component will redirect to
  21. * the next step in the $steps array. (This is helpful for returning a user to
  22. * the expected step after editing a previous step w/o them having to navigate through
  23. * each step in between.)
  24. *
  25. * @var boolean
  26. */
  27. public $autoAdvance = true;
  28. /**
  29. * Option to automatically reset if the wizard does not follow "normal"
  30. * operation. (ie. manual url changing, navigation away and returning, etc.)
  31. * Set this to false if you want the Wizard to return to the "expected step"
  32. * after invalid navigation.
  33. *
  34. * @var boolean
  35. */
  36. public $autoReset = false;
  37. /**
  38. * If no processCallback() exists for the current step, the component will automatically
  39. * validate the model data against the models included in the controller's uses array.
  40. *
  41. * @var boolean
  42. */
  43. public $autoValidate = false;
  44. /**
  45. * List of steps, in order, that are to be included in the wizard.
  46. * basic example: $steps = array('contact', 'payment', 'confirm');
  47. *
  48. * The $steps array can also contain nested steps arrays of the same format but
  49. * must be wrapped by a branch group.
  50. * plot-branched example: $steps = array('job_application',
  51. * array('degree' => array('college', 'degree_type'), 'nodegree' => 'experience'),
  52. * 'confirm');
  53. *
  54. * The 'branchnames' (ie 'degree', 'nodegree') are arbitrary but used as selectors for the branch() and unbranch() methods. Branches
  55. * can point to either another steps array or a single step. The first branch in a group that hasn't been skipped (see branch())
  56. * is included by default (if $defaultBranch = true).
  57. *
  58. * @var array
  59. */
  60. public $steps = array();
  61. /**
  62. * Controller action that processes your step.
  63. *
  64. * @var string
  65. */
  66. public $wizardAction = 'wizard';
  67. /**
  68. * Url to be redirected to after the wizard has been completed.
  69. * Controller::afterComplete() is called directly before redirection.
  70. *
  71. * @var mixed
  72. */
  73. public $completeUrl = '/';
  74. /**
  75. * Url to be redirected to after 'Cancel' submit button has been pressed by user.
  76. *
  77. * @var mixed
  78. */
  79. public $cancelUrl = '/';
  80. /**
  81. * If true, the first "non-skipped" branch in a group will be used if a branch has
  82. * not been included specifically.
  83. *
  84. * @var boolean
  85. */
  86. public $defaultBranch = true;
  87. /**
  88. * If true, the user will not be allowed to edit previously completed steps. They will be
  89. * "locked down" to the current step.
  90. *
  91. * @var boolean
  92. */
  93. public $lockdown = false;
  94. /**
  95. * Internal step tracking.
  96. *
  97. * @var string
  98. */
  99. protected $_currentStep = null;
  100. /**
  101. * Holds the session key for data storage.
  102. *
  103. * @var string
  104. */
  105. protected $_sessionKey = null;
  106. /**
  107. * Other session keys used.
  108. *
  109. * @var string
  110. */
  111. protected $_configKey = null;
  112. protected $_branchKey = null;
  113. /**
  114. * Holds the array based url for redirecting.
  115. *
  116. * @var array
  117. */
  118. protected $_wizardUrl = array();
  119. /**
  120. * Other components used.
  121. *
  122. * @var array
  123. */
  124. public $components = array('Session');
  125. /**
  126. * Initializes WizardComponent for use in the controller
  127. *
  128. * @param Controller $Controller A reference to the instantiating controller object
  129. */
  130. function initialize(Controller $Controller, $settings = array()) {
  131. $this->Controller = $Controller;
  132. $this->_sessionKey = $this->Session->check('Wizard.complete') ? 'Wizard.complete' : 'Wizard.' . $this->Controller->name;
  133. $this->_configKey = 'Wizard.config';
  134. $this->_branchKey = 'Wizard.branches.' . $this->Controller->name;
  135. $this->_set($settings);
  136. }
  137. /**
  138. * Component startup method.
  139. *
  140. * @param Controller $controller A reference to the instantiating controller object
  141. */
  142. function startup(Controller $Controller) {
  143. $this->steps = $this->_parseSteps($this->steps);
  144. $this->config('wizardAction', $this->wizardAction);
  145. $this->config('steps', $this->steps);
  146. if (!in_array('Libs.Wizard', $this->Controller->helpers) && !array_key_exists('Libs.Wizard', $this->Controller->helpers)) {
  147. $this->Controller->helpers[] = 'Libs.Wizard';
  148. }
  149. }
  150. /**
  151. * Main Component method.
  152. *
  153. * @param string $step Name of step associated in $this->steps to be processed.
  154. */
  155. function process($step) {
  156. if (isset($this->Controller->params['form']['Cancel'])) {
  157. if (method_exists($this->Controller, '_beforeCancel')) {
  158. $this->Controller->_beforeCancel($this->_getExpectedStep());
  159. }
  160. $this->reset();
  161. $this->Controller->redirect($this->cancelUrl);
  162. }
  163. if (empty($step)) {
  164. if ($this->Session->check('Wizard.complete')) {
  165. if (method_exists($this->Controller, '_afterComplete')) {
  166. $this->Controller->_afterComplete();
  167. }
  168. $this->reset();
  169. $this->Controller->redirect($this->completeUrl);
  170. }
  171. $this->autoReset = false;
  172. }
  173. else if ($step == 'reset') {
  174. if (!$this->lockdown) {
  175. $this->reset();
  176. }
  177. }
  178. else {
  179. if ($this->_validStep($step)) {
  180. $this->_setCurrentStep($step);
  181. if (!empty($this->Controller->data) && !isset($this->Controller->params['form']['Previous'])) {
  182. $proceed = false;
  183. $processCallback = '_' . Inflector::variable('process_' . $this->_currentStep);
  184. if (method_exists($this->Controller, $processCallback)) {
  185. $proceed = $this->Controller->$processCallback();
  186. }
  187. else if ($this->autoValidate) {
  188. $proceed = $this->_validateData();
  189. }
  190. else {
  191. trigger_error(sprintf(__d('libs', 'Process Callback not found. Please create Controller::%s'), $processCallback), E_USER_WARNING);
  192. }
  193. if ($proceed) {
  194. $this->save();
  195. if (next($this->steps)) {
  196. if ($this->autoAdvance) {
  197. $this->redirect();
  198. }
  199. $this->redirect(current($this->steps));
  200. }
  201. else {
  202. $this->Session->write('Wizard.complete', $this->read());
  203. $this->reset();
  204. $this->Controller->redirect($this->wizardAction);
  205. }
  206. }
  207. }
  208. else if (isset($this->Controller->params['form']['Previous']) && prev($this->steps)) {
  209. $this->redirect(current($this->steps));
  210. }
  211. else if ($this->Session->check("$this->_sessionKey.$this->_currentStep")) {
  212. $this->Controller->data = $this->read($this->_currentStep);
  213. }
  214. $prepareCallback = '_' . Inflector::variable('prepare_' . $this->_currentStep);
  215. if (method_exists($this->Controller, $prepareCallback)) {
  216. $this->Controller->$prepareCallback();
  217. }
  218. $this->config('activeStep', $this->_currentStep);
  219. return ($this->Controller->autoRender) ? $this->Controller->render($this->_currentStep) : true;
  220. }
  221. else {
  222. trigger_error(sprintf(__d('libs', 'Step validation: %s is not a valid step.'), $step), E_USER_WARNING);
  223. }
  224. }
  225. if ($step != 'reset' && $this->autoReset) {
  226. $this->reset();
  227. }
  228. $this->redirect();
  229. }
  230. /**
  231. * Selects a branch to be used in the steps array. The first branch in a group is included by default.
  232. *
  233. * @param string $name Branch name to be included in steps.
  234. * @param boolean $skip Branch will be skipped instead of included if true.
  235. */
  236. function branch($name, $skip = false) {
  237. $branches = array();
  238. if ($this->Session->check($this->_branchKey)) {
  239. $branches = $this->Session->read($this->_branchKey);
  240. }
  241. unset($branches[$name]);
  242. $value = ($skip) ? 'skip' : 'branch';
  243. $branches[$name] = $value;
  244. $this->Session->write($this->_branchKey, $branches);
  245. }
  246. /**
  247. * Saves configuration details for use in WizardHelper or returns a config value.
  248. * This is method usually handled only by the component.
  249. *
  250. * @param string $name Name of configuration variable.
  251. * @param mixed $value Value to be stored.
  252. *
  253. * @return mixed
  254. */
  255. function config($name, $value = null) {
  256. if ($value == null) {
  257. return $this->Session->read($this->_configKey . '.' . $name);
  258. }
  259. $this->Session->write($this->_configKey . '.' . $name, $value);
  260. }
  261. /**
  262. * Get the data from the Session that has been stored by the WizardComponent.
  263. *
  264. * @param mixed $name The name of the session variable (or a path as sent to Set.extract)
  265. * @return mixed The value of the session variable
  266. */
  267. function read($key = null) {
  268. if ($key == null) {
  269. return $this->Session->read($this->_sessionKey);
  270. }
  271. else {
  272. $wizardData = $this->Session->read("$this->_sessionKey.$key");
  273. if (!empty($wizardData)) {
  274. return $wizardData;
  275. }
  276. else {
  277. return null;
  278. }
  279. }
  280. }
  281. /**
  282. * Handles Wizard redirection. A null url will redirect to the "expected" step.
  283. *
  284. * @param string $step Stepname to be redirected to.
  285. * @param integer $status Optional HTTP status code (eg: 404)
  286. * @param boolean $exit If true, exit() will be called after the redirect
  287. * @see Controller::redirect()
  288. */
  289. function redirect($step = null, $status = null, $exit = true) {
  290. if ($step == null) {
  291. $step = $this->_getExpectedStep();
  292. }
  293. $url = array('controller' => strtolower($this->Controller->name), 'action' => $this->wizardAction, 'step' => $step);
  294. $this->Controller->redirect($url, $status, $exit);
  295. }
  296. /**
  297. * Resets the wizard by deleting the wizard session.
  298. */
  299. function resetWizard() {
  300. $this->reset();
  301. }
  302. /**
  303. * Resets the wizard by deleting the wizard session.
  304. */
  305. function reset() {
  306. $this->Session->delete($this->_branchKey);
  307. $this->Session->delete($this->_sessionKey);
  308. }
  309. /**
  310. * Saves the data from the current step into the Session.
  311. *
  312. * Please note: This is normally called automatically by the component after
  313. * a successful _processCallback, but can be called directly for advanced navigation purposes.
  314. */
  315. function save() {
  316. $this->Session->write($this->_sessionKey . '.' . $this->_currentStep, $this->Controller->data);
  317. }
  318. /**
  319. * Removes a branch from the steps array.
  320. *
  321. * @param string $branch Name of branch to be removed from steps array.
  322. */
  323. function unbranch($branch) {
  324. $this->Session->delete($this->_branchKey . '.' . $branch);
  325. }
  326. /**
  327. * Finds the first incomplete step (i.e. step data not saved in Session).
  328. *
  329. * @return string
  330. */
  331. function _getExpectedStep() {
  332. foreach ($this->steps as $step) {
  333. if (!$this->Session->check("$this->_sessionKey.$step")) {
  334. $this->config('expectedStep', $step);
  335. return $step;
  336. }
  337. }
  338. return false;
  339. }
  340. /**
  341. * Saves configuration details for use in WizardHelper.
  342. *
  343. * @return mixed
  344. */
  345. function _branchType($branch) {
  346. if ($this->Session->check($this->_branchKey . '.' . $branch )) {
  347. return $this->Session->read($this->_branchKey . '.' . $branch);
  348. }
  349. return false;
  350. }
  351. /**
  352. * Parses the steps array by stripping off nested arrays not included in the branches
  353. * and returns a simple array with the correct steps.
  354. *
  355. * @param array $steps Array to be parsed for nested arrays and returned as simple array.
  356. *
  357. * @return array
  358. */
  359. function _parseSteps($steps) {
  360. $parsed = array();
  361. foreach ($steps as $key => $name) {
  362. if (is_array($name)) {
  363. foreach ($name as $branchName => $step) {
  364. $branchType = $this->_branchType($branchName);
  365. if ($branchType) {
  366. if ($branchType !== 'skip') {
  367. $branch = $branchName;
  368. }
  369. }
  370. else if (empty($branch) && $this->defaultBranch) {
  371. $branch = $branchName;
  372. }
  373. }
  374. if (!empty($branch)) {
  375. if (is_array($name[$branch])) {
  376. $parsed = array_merge($parsed, $this->_parseSteps($name[$branch]));
  377. }
  378. else {
  379. $parsed[] = $name[$branch];
  380. }
  381. }
  382. }
  383. else {
  384. $parsed[] = $name;
  385. }
  386. }
  387. return $parsed;
  388. }
  389. /**
  390. * Moves internal array pointer of $this->steps to $step and sets $this->_currentStep.
  391. *
  392. * @param $step Step to point to.
  393. */
  394. function _setCurrentStep($step) {
  395. $this->_currentStep = reset($this->steps);
  396. while (current($this->steps) != $step) {
  397. $this->_currentStep = next($this->steps);
  398. }
  399. }
  400. /**
  401. * Validates controller data with the correct model if the model is included in
  402. * the controller's uses array. This only occurs if $autoValidate = true and there
  403. * is no processCallback in the controller for the current step.
  404. *
  405. * @return boolean
  406. */
  407. function _validateData() {
  408. $controller = $this->Controller;
  409. foreach ($controller->data as $model => $data) {
  410. if (in_array($model, $controller->uses)) {
  411. $controller->{$model}->set($data);
  412. if (!$controller->{$model}->validates()) {
  413. return false;
  414. }
  415. }
  416. }
  417. return true;
  418. }
  419. /**
  420. * Validates the $step in two ways:
  421. * 1. Validates that the step exists in $this->steps array.
  422. * 2. Validates that the step is either before or exactly the expected step.
  423. *
  424. * @param $step Step to validate.
  425. *
  426. * @return mixed
  427. */
  428. function _validStep($step) {
  429. if (in_array($step, $this->steps)) {
  430. if ($this->lockdown) {
  431. return array_search($step, $this->steps) == array_search($this->_getExpectedStep(), $this->steps);
  432. }
  433. return array_search($step, $this->steps) <= array_search($this->_getExpectedStep(), $this->steps);
  434. }
  435. return false;
  436. }
  437. }