├── .gitignore ├── README.md ├── controllers └── components │ └── wizard.php └── views └── helpers └── wizard.php /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | .DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CakePHP Wizard Plugin 2 | 3 | The Wizard plugin for CakePHP automates several aspects of multi-page forms including data persistence, form preparation and unique data processing, wizard resetting (manual and automatic), user navigation, and plot-branching navigation while maintaining flexibility with custom validation and completion callbacks. 4 | 5 | ## Installation 6 | 7 | * Clone/Copy the files in this directory into `app/plugins/wizard` 8 | * Include the wizard component in your controller: 9 | * `var $components = array('Wizard.Wizard');` 10 | 11 | ## Documentation 12 | 13 | Detailed documentation, including usage examples, can be found in the [GitHub wiki](http://github.com/jaredhoyt/cakephp-wizard/wiki). 14 | 15 | ## Reporting issues 16 | 17 | If you have any issues with this plugin, please open a ticket on [Lighthouse](http://jaredhoyt.lighthouseapp.com/projects/60073-cakephp-wizard). 18 | -------------------------------------------------------------------------------- /controllers/components/wizard.php: -------------------------------------------------------------------------------- 1 | array('college', 'degree_type'), 'nodegree' => 'experience'), 'confirm'); 52 | * 53 | * The 'branchnames' (ie 'degree', 'nodegree') are arbitrary but used as selectors for the branch() and unbranch() methods. Branches 54 | * can point to either another steps array or a single step. The first branch in a group that hasn't been skipped (see branch()) 55 | * is included by default (if $defaultBranch = true). 56 | * 57 | * @var array 58 | * @access public 59 | */ 60 | var $steps = array(); 61 | /** 62 | * Controller action that processes your step. 63 | * 64 | * @var string 65 | * @access public 66 | */ 67 | var $wizardAction = 'wizard'; 68 | /** 69 | * Url to be redirected to after the wizard has been completed. 70 | * Controller::afterComplete() is called directly before redirection. 71 | * 72 | * @var mixed 73 | * @access public 74 | */ 75 | var $completeUrl = '/'; 76 | /** 77 | * Url to be redirected to after 'Cancel' submit button has been pressed by user. 78 | * 79 | * @var mixed 80 | * @access public 81 | */ 82 | var $cancelUrl = '/'; 83 | /** 84 | * Url to be redirected to after 'Draft' submit button has been pressed by user. 85 | * 86 | * @var mixed 87 | * @access public 88 | */ 89 | var $draftUrl = '/'; 90 | /** 91 | * If true, the first "non-skipped" branch in a group will be used if a branch has 92 | * not been included specifically. 93 | * 94 | * @var boolean 95 | * @access public 96 | */ 97 | var $defaultBranch = true; 98 | /** 99 | * If true, the user will not be allowed to edit previously completed steps. They will be 100 | * "locked down" to the current step. 101 | * 102 | * @var boolean 103 | * @access public 104 | */ 105 | var $lockdown = false; 106 | /** 107 | * If true, the component will render views found in views/{wizardAction}/{step}.ctp rather 108 | * than views/{step}.ctp. 109 | * 110 | * @var boolean 111 | * @access public 112 | */ 113 | var $nestedViews = false; 114 | /** 115 | * Internal step tracking. 116 | * 117 | * @var string 118 | * @access protected 119 | */ 120 | var $_currentStep = null; 121 | /** 122 | * Holds the session key for data storage. 123 | * 124 | * @var string 125 | * @access protected 126 | */ 127 | var $_sessionKey = null; 128 | /** 129 | * Other session keys used. 130 | * 131 | * @var string 132 | * @access protected 133 | */ 134 | var $_configKey = null; 135 | var $_branchKey = null; 136 | /** 137 | * Holds the array based url for redirecting. 138 | * 139 | * @var array 140 | * @access protected 141 | */ 142 | var $_wizardUrl = array(); 143 | /** 144 | * Other components used. 145 | * 146 | * @var array 147 | * @access public 148 | */ 149 | var $components = array('Session'); 150 | /** 151 | * Initializes WizardComponent for use in the controller 152 | * 153 | * @param object $controller A reference to the instantiating controller object 154 | * @access public 155 | */ 156 | function initialize(&$controller, $settings = array()) { 157 | $this->controller =& $controller; 158 | $this->_set($settings); 159 | 160 | $this->_sessionKey = $this->Session->check('Wizard.complete') ? 'Wizard.complete' : 'Wizard.' . $controller->name; 161 | $this->_configKey = 'Wizard.config'; 162 | $this->_branchKey = 'Wizard.branches.' . $controller->name; 163 | } 164 | /** 165 | * Component startup method. 166 | * 167 | * @param object $controller A reference to the instantiating controller object 168 | * @access public 169 | */ 170 | function startup(&$controller) { 171 | $this->steps = $this->_parseSteps($this->steps); 172 | 173 | $this->config('wizardAction', $this->wizardAction); 174 | $this->config('steps', $this->steps); 175 | 176 | if (!in_array('Wizard.Wizard', $this->controller->helpers) && !array_key_exists('Wizard.Wizard', $this->controller->helpers)) { 177 | $this->controller->helpers[] = 'Wizard.Wizard'; 178 | } 179 | } 180 | /** 181 | * Main Component method. 182 | * 183 | * @param string $step Name of step associated in $this->steps to be processed. 184 | * @access public 185 | */ 186 | function process($step) { 187 | if (isset($this->controller->params['form']['Cancel'])) { 188 | if (method_exists($this->controller, '_beforeCancel')) { 189 | $this->controller->_beforeCancel($this->_getExpectedStep()); 190 | } 191 | $this->reset(); 192 | $this->controller->redirect($this->cancelUrl); 193 | } 194 | if (isset($this->controller->params['form']['Draft'])) { 195 | if (method_exists($this->controller, '_saveDraft')) { 196 | $draft = array('_draft' => array('current' => array('step' => $step, 'data' => $this->controller->data))); 197 | $this->controller->_saveDraft(array_merge_recursive((array)$this->read(), $draft)); 198 | } 199 | 200 | $this->reset(); 201 | $this->controller->redirect($this->draftUrl); 202 | } 203 | 204 | if (empty($step)) { 205 | if ($this->Session->check('Wizard.complete')) { 206 | if (method_exists($this->controller, '_afterComplete')) { 207 | $this->controller->_afterComplete(); 208 | } 209 | $this->reset(); 210 | $this->controller->redirect($this->completeUrl); 211 | } 212 | 213 | $this->autoReset = false; 214 | } elseif ($step == 'reset') { 215 | if (!$this->lockdown) { 216 | $this->reset(); 217 | } 218 | } else { 219 | if ($this->_validStep($step)) { 220 | $this->_setCurrentStep($step); 221 | 222 | if (!empty($this->controller->data) && !isset($this->controller->params['form']['Previous'])) { 223 | $proceed = false; 224 | 225 | $processCallback = '_' . Inflector::variable('process_' . $this->_currentStep); 226 | if (method_exists($this->controller, $processCallback)) { 227 | $proceed = $this->controller->$processCallback(); 228 | } elseif ($this->autoValidate) { 229 | $proceed = $this->_validateData(); 230 | } else { 231 | trigger_error(sprintf(__('Process Callback not found. Please create Controller::%s', true), $processCallback), E_USER_WARNING); 232 | } 233 | 234 | if ($proceed) { 235 | $this->save(); 236 | 237 | if (next($this->steps)) { 238 | if ($this->autoAdvance) { 239 | $this->redirect(); 240 | } 241 | $this->redirect(current($this->steps)); 242 | } else { 243 | $this->Session->write('Wizard.complete', $this->read()); 244 | $this->reset(); 245 | 246 | $this->controller->redirect($this->wizardAction); 247 | } 248 | } 249 | } elseif (isset($this->controller->params['form']['Previous']) && prev($this->steps)) { 250 | $this->redirect(current($this->steps)); 251 | } elseif ($this->Session->check("$this->_sessionKey._draft.current")) { 252 | $this->controller->data = $this->read('_draft.current.data'); 253 | $this->Session->delete("$this->_sessionKey._draft.current"); 254 | } elseif ($this->Session->check("$this->_sessionKey.$this->_currentStep")) { 255 | $this->controller->data = $this->read($this->_currentStep); 256 | } 257 | 258 | $prepareCallback = '_' . Inflector::variable('prepare_' . $this->_currentStep); 259 | if (method_exists($this->controller, $prepareCallback)) { 260 | $this->controller->$prepareCallback(); 261 | } 262 | 263 | $this->config('activeStep', $this->_currentStep); 264 | 265 | if ($this->nestedViews) { 266 | $this->controller->viewPath .= '/' . $this->wizardAction; 267 | } 268 | 269 | return $this->controller->autoRender ? $this->controller->render($this->_currentStep) : true; 270 | } else { 271 | trigger_error(sprintf(__('Step validation: %s is not a valid step.', true), $step), E_USER_WARNING); 272 | } 273 | } 274 | 275 | if ($step != 'reset' && $this->autoReset) { 276 | $this->reset(); 277 | } 278 | 279 | $this->redirect(); 280 | } 281 | /** 282 | * Selects a branch to be used in the steps array. The first branch in a group is included by default. 283 | * 284 | * @param string $name Branch name to be included in steps. 285 | * @param boolean $skip Branch will be skipped instead of included if true. 286 | * @access public 287 | */ 288 | function branch($name, $skip = false) { 289 | $branches = array(); 290 | 291 | if ($this->Session->check($this->_branchKey)) { 292 | $branches = $this->Session->read($this->_branchKey); 293 | } 294 | 295 | if (isset($branches[$name])) { 296 | unset($branches[$name]); 297 | } 298 | 299 | $value = $skip ? 'skip' : 'branch'; 300 | $branches[$name] = $value; 301 | 302 | $this->Session->write($this->_branchKey, $branches); 303 | } 304 | /** 305 | * Saves configuration details for use in WizardHelper or returns a config value. 306 | * This is method usually handled only by the component. 307 | * 308 | * @param string $name Name of configuration variable. 309 | * @param mixed $value Value to be stored. 310 | * @return mixed 311 | * @access public 312 | */ 313 | function config($name, $value = null) { 314 | if ($value == null) { 315 | return $this->Session->read("$this->_configKey.$name"); 316 | } 317 | $this->Session->write("$this->_configKey.$name", $value); 318 | } 319 | /** 320 | * Loads previous draft session. 321 | * 322 | * @param array $draft Session data of same format passed to Controller::_saveDraft() 323 | * @see WizardComponent::process() 324 | * @access public 325 | */ 326 | function loadDraft($draft = array()) { 327 | if (!empty($draft['_draft']['current']['step'])) { 328 | $this->restore($draft); 329 | $this->redirect($draft['_draft']['current']['step']); 330 | } 331 | $this->redirect(); 332 | } 333 | /** 334 | * Get the data from the Session that has been stored by the WizardComponent. 335 | * 336 | * @param mixed $name The name of the session variable (or a path as sent to Set.extract) 337 | * @return mixed The value of the session variable 338 | * @access public 339 | */ 340 | function read($key = null) { 341 | if ($key == null) { 342 | return $this->Session->read($this->_sessionKey); 343 | } else { 344 | $wizardData = $this->Session->read("$this->_sessionKey.$key"); 345 | return !empty($wizardData) ? $wizardData : null; 346 | } 347 | } 348 | /** 349 | * Handles Wizard redirection. A null url will redirect to the "expected" step. 350 | * 351 | * @param string $step Stepname to be redirected to. 352 | * @param integer $status Optional HTTP status code (eg: 404) 353 | * @param boolean $exit If true, exit() will be called after the redirect 354 | * @see Controller::redirect() 355 | * @access public 356 | */ 357 | function redirect($step = null, $status = null, $exit = true) { 358 | if ($step == null) { 359 | $step = $this->_getExpectedStep(); 360 | } 361 | $url = array('controller' => Inflector::underscore($this->controller->name), 'action' => $this->wizardAction, $step); 362 | $this->controller->redirect($url, $status, $exit); 363 | } 364 | /** 365 | * Resets the wizard by deleting the wizard session. 366 | * 367 | * @access public 368 | */ 369 | function resetWizard() { 370 | $this->reset(); 371 | } 372 | /** 373 | * Resets the wizard by deleting the wizard session. 374 | * 375 | * @access public 376 | */ 377 | function reset() { 378 | $this->Session->delete($this->_branchKey); 379 | $this->Session->delete($this->_sessionKey); 380 | } 381 | /** 382 | * Sets data into controller's wizard session. Particularly useful if the data 383 | * originated from WizardComponent::read() as this will restore a previous session. 384 | * 385 | * @param array $data Data to be written to controller's wizard session. 386 | * @access public 387 | */ 388 | function restore($data = array()) { 389 | $this->Session->write($this->_sessionKey, $data); 390 | } 391 | /** 392 | * Saves the data from the current step into the Session. 393 | * 394 | * Please note: This is normally called automatically by the component after 395 | * a successful _processCallback, but can be called directly for advanced navigation purposes. 396 | * 397 | * @access public 398 | */ 399 | function save($step = null, $data = null) { 400 | if (is_null($step)) { 401 | $step = $this->_currentStep; 402 | } 403 | if (is_null($data)) { 404 | $data = $this->controller->data; 405 | } 406 | $this->Session->write("$this->_sessionKey.$step", $data); 407 | } 408 | /** 409 | * Removes a branch from the steps array. 410 | * 411 | * @param string $branch Name of branch to be removed from steps array. 412 | * @access public 413 | */ 414 | function unbranch($branch) { 415 | $this->Session->delete("$this->_branchKey.$branch"); 416 | } 417 | /** 418 | * Finds the first incomplete step (i.e. step data not saved in Session). 419 | * 420 | * @return string $step or false if complete 421 | * @access protected 422 | */ 423 | function _getExpectedStep() { 424 | foreach ($this->steps as $step) { 425 | if (!$this->Session->check("$this->_sessionKey.$step")) { 426 | $this->config('expectedStep', $step); 427 | return $step; 428 | } 429 | } 430 | return false; 431 | } 432 | /** 433 | * Saves configuration details for use in WizardHelper. 434 | * 435 | * @return mixed 436 | * @access protected 437 | */ 438 | function _branchType($branch) { 439 | if ($this->Session->check("$this->_branchKey.$branch")) { 440 | return $this->Session->read("$this->_branchKey.$branch"); 441 | } 442 | return false; 443 | } 444 | /** 445 | * Parses the steps array by stripping off nested arrays not included in the branches 446 | * and returns a simple array with the correct steps. 447 | * 448 | * @param array $steps Array to be parsed for nested arrays and returned as simple array. 449 | * @return array 450 | * @access protected 451 | */ 452 | function _parseSteps($steps) { 453 | $parsed = array(); 454 | 455 | foreach ($steps as $key => $name) { 456 | if (is_array($name)) { 457 | foreach ($name as $branchName => $step) { 458 | $branchType = $this->_branchType($branchName); 459 | 460 | if ($branchType) { 461 | if ($branchType !== 'skip') { 462 | $branch = $branchName; 463 | } 464 | } elseif (empty($branch) && $this->defaultBranch) { 465 | $branch = $branchName; 466 | } 467 | } 468 | 469 | if (!empty($branch)) { 470 | if (is_array($name[$branch])) { 471 | $parsed = array_merge($parsed, $this->_parseSteps($name[$branch])); 472 | } else { 473 | $parsed[] = $name[$branch]; 474 | } 475 | } 476 | } else { 477 | $parsed[] = $name; 478 | } 479 | } 480 | return $parsed; 481 | } 482 | /** 483 | * Moves internal array pointer of $this->steps to $step and sets $this->_currentStep. 484 | * 485 | * @param $step Step to point to. 486 | * @access protected 487 | */ 488 | function _setCurrentStep($step) { 489 | $this->_currentStep = reset($this->steps); 490 | 491 | while(current($this->steps) != $step) { 492 | $this->_currentStep = next($this->steps); 493 | } 494 | } 495 | /** 496 | * Validates controller data with the correct model if the model is included in 497 | * the controller's uses array. This only occurs if $autoValidate = true and there 498 | * is no processCallback in the controller for the current step. 499 | * 500 | * @return boolean 501 | * @access protected 502 | */ 503 | function _validateData() { 504 | $controller =& $this->controller; 505 | 506 | foreach ($controller->data as $model => $data) { 507 | if (in_array($model, $controller->uses)) { 508 | $controller->{$model}->set($data); 509 | 510 | if (!$controller->{$model}->validates()) { 511 | return false; 512 | } 513 | } 514 | } 515 | return true; 516 | } 517 | /** 518 | * Validates the $step in two ways: 519 | * 1. Validates that the step exists in $this->steps array. 520 | * 2. Validates that the step is either before or exactly the expected step. 521 | * 522 | * @param $step Step to validate. 523 | * @return mixed 524 | * @access protected 525 | */ 526 | function _validStep($step) { 527 | if (in_array($step, $this->steps)) { 528 | if ($this->lockdown) { 529 | return (array_search($step, $this->steps) == array_search($this->_getExpectedStep(), $this->steps)); 530 | } 531 | return (array_search($step, $this->steps) <= array_search($this->_getExpectedStep(), $this->steps)); 532 | } 533 | return false; 534 | } 535 | } 536 | ?> -------------------------------------------------------------------------------- /views/helpers/wizard.php: -------------------------------------------------------------------------------- 1 | Session->read('Wizard.config'); 29 | } else { 30 | $wizardData = $this->Session->read('Wizard.config.'.$key); 31 | if (!empty($wizardData)) { 32 | return $wizardData; 33 | } else { 34 | return null; 35 | } 36 | } 37 | } 38 | /** 39 | * undocumented function 40 | * 41 | * @param string $title 42 | * @param string $step 43 | * @param string $htmlAttributes 44 | * @param string $confirmMessage 45 | * @param string $escapeTitle 46 | * @return string link to a specific step 47 | */ 48 | function link($title, $step = null, $htmlAttributes = array(), $confirmMessage = false, $escapeTitle = true) { 49 | if ($step == null) { 50 | $step = $title; 51 | } 52 | $wizardAction = $this->config('wizardAction'); 53 | 54 | return $this->Html->link($title, $wizardAction.$step, $htmlAttributes, $confirmMessage, $escapeTitle); 55 | } 56 | /** 57 | * Retrieve the step number of the specified step name, or the active step 58 | * 59 | * @param string $step optional name of step 60 | * @param string $shiftIndex optional offset of returned array index. Default 1 61 | * @return string step number. Returns false if not found 62 | */ 63 | function stepNumber($step = null, $shiftIndex = 1) { 64 | if ($step == null) { 65 | $step = $this->config('activeStep'); 66 | } 67 | 68 | $steps = $this->config('steps'); 69 | 70 | if (in_array($step, $steps)) { 71 | return array_search($step, $steps) + $shiftIndex; 72 | } else { 73 | return false; 74 | } 75 | } 76 | /** 77 | * Returns a set of html elements containing links for each step in the wizard. 78 | * 79 | * @param string $titles 80 | * @param string $attributes pass a value for 'wrap' to change the default tag used 81 | * @param string $htmlAttributes 82 | * @param string $confirmMessage 83 | * @param string $escapeTitle 84 | * @return string 85 | */ 86 | function progressMenu($titles = array(), $attributes = array(), $htmlAttributes = array(), $confirmMessage = false, $escapeTitle = true) { 87 | $wizardConfig = $this->config(); 88 | extract($wizardConfig); 89 | 90 | $attributes = array_merge(array('wrap' => 'div'), $attributes); 91 | extract($attributes); 92 | 93 | $incomplete = null; 94 | 95 | foreach ($steps as $title => $step) { 96 | $title = empty($titles[$step]) ? $step : $titles[$step]; 97 | 98 | if (!$incomplete) { 99 | if ($step == $expectedStep) { 100 | $incomplete = true; 101 | $class = 'expected'; 102 | } else { 103 | $class = 'complete'; 104 | } 105 | if ($step == $activeStep) { 106 | $class .= ' active'; 107 | } 108 | $this->output .= "<$wrap class='$class'>" . $this->Html->link($title, array('action' => $wizardAction, $step), $htmlAttributes, $confirmMessage, $escapeTitle) . ""; 109 | } else { 110 | $this->output .= "<$wrap class='incomplete'>" . $title . ""; 111 | } 112 | } 113 | 114 | return $this->output; 115 | } 116 | } 117 | ?> --------------------------------------------------------------------------------