SproutCMS

This is the code documentation for the SproutCMS project

source of /sprout/Controllers/MultiStepFormController.php

  1. <?php
  2. /*
  3.  * Copyright (C) 2017 Karmabunny Pty Ltd.
  4.  *
  5.  * This file is a part of SproutCMS.
  6.  *
  7.  * SproutCMS is free software: you can redistribute it and/or modify it under the terms
  8.  * of the GNU General Public License as published by the Free Software Foundation, either
  9.  * version 2 of the License, or (at your option) any later version.
  10.  *
  11.  * For more information, visit <http://getsproutcms.com>.
  12.  */
  13.  
  14. namespace Sprout\Controllers;
  15.  
  16. use Exception;
  17.  
  18. use Kohana_404_Exception;
  19.  
  20. use karmabunny\pdb\Exceptions\QueryException;
  21. use Sprout\Helpers\Notification;
  22. use Sprout\Helpers\Pdb;
  23. use Sprout\Helpers\Session;
  24. use Sprout\Helpers\Url;
  25. use Sprout\Helpers\Validator;
  26. use Sprout\Helpers\View;
  27. use Sprout\Helpers\Sprout;
  28.  
  29.  
  30. /**
  31. * Supports multistep forms
  32. */
  33. abstract class MultiStepFormController extends Controller {
  34. /**
  35.   * Steps and functions/views used to implement them
  36.   * Keys are step numbers, values are function (and matching view) names
  37.   * e.g. [1 => 'intro', 2 => 'personal_details']
  38.   */
  39. protected $steps = [];
  40.  
  41. /** Key for partitioning session data associated with this form, i.e. $_SESSION[$this->session_key] */
  42. protected $session_key = 'DUMMY';
  43.  
  44. /** Route to form, relative to site root */
  45. protected $route = 'multistep';
  46.  
  47. /** Page title to be displayed on form steps */
  48. protected $page_title = 'Form';
  49.  
  50. /** Appendage to route, where user is redirected after save on final step */
  51. protected $complete_function = 'complete';
  52.  
  53. /** Directory in which views are stored, without trailing slash */
  54. protected $view_dir = '';
  55.  
  56. /** Table into which data will be saved at the final step */
  57. protected $table = '';
  58.  
  59.  
  60. /**
  61.   * The method used to drive the forms at each step
  62.   * This should be called by the entry function of the subclass controller
  63.   * @param int $step The current step
  64.   * @return View The view containing the form for that step
  65.   */
  66. protected function form($step = -1)
  67. {
  68. $step = (int) $step;
  69.  
  70. // Allow arrays with 0-based or 1-based ordering
  71. $first_step = $this->firstStep();
  72. if ($step < $first_step) $step = $first_step;
  73.  
  74. $session = $this->getSession();
  75. $this->checkStep($session, $step);
  76.  
  77. $data = @$session['data'];
  78. if (!is_array($data)) $data = array();
  79.  
  80. $view_name = $this->steps[$step];
  81. if (!$view_name) {
  82. throw new Kohana_404_Exception();
  83. }
  84.  
  85. $view = new View("{$this->view_dir}/{$view_name}");
  86. $view->submit_url = "{$this->route}/submit/{$step}";
  87. $view->session_key = $this->session_key;
  88. $view->step = $step;
  89. $view->steps = count($this->steps);
  90. $view->data = $data;
  91. if (!empty($session['field_errors'])) {
  92. $view->errors = $session['field_errors'];
  93. } else {
  94. $view->errors = [];
  95. }
  96.  
  97. // Allow loading custom data (e.g. for select fields)
  98. if (method_exists($this, $view_name)) {
  99. $this->$view_name($view);
  100. }
  101.  
  102. return $view;
  103. }
  104.  
  105.  
  106. /**
  107.   * Display the thanks message after the process has been completed
  108.   * @return void
  109.   */
  110. public function complete()
  111. {
  112. $view = new View("{$this->view_dir}/complete");
  113.  
  114. $page_view = new View('skin/inner');
  115. $page_view->main_content = $view->render();
  116. $page_view->page_title = "{$this->page_title}: complete";
  117. $page_view->controller_name = $this->getCssClassName();
  118. echo $page_view->render();
  119. }
  120.  
  121.  
  122. /**
  123.   * The method used to drive the form submissions at each step
  124.   * This should be called by the submit function of the subclass controller
  125.   * @param int $step The current step
  126.   * @return void On the final step, the save() method is called. On earlier
  127.   * steps, the user is redirected to the form for the next step.
  128.   */
  129. protected function submit($step)
  130. {
  131. $step = (int) $step;
  132.  
  133. $session = &self::getSession();
  134. $this->checkStep($session, $step);
  135.  
  136. $handler_function = $this->steps[$step];
  137. if (!$handler_function) throw new Kohana_404_Exception();
  138. $handler_function .= 'Submit';
  139.  
  140. if (!isset($session['data'])) $session['data'] = array();
  141.  
  142. if (!method_exists($this, $handler_function)) {
  143. throw new Exception("Missing handler $handler_function");
  144. }
  145.  
  146. $this->$handler_function($step);
  147.  
  148. // Allow arrays with 0-based or 1-based ordering
  149. $step_nums = array_keys($this->steps);
  150. $last_step = end($step_nums);
  151. if ($step == $last_step) {
  152. $this->save();
  153. } else {
  154. ++$step;
  155. $session['step'] = $step;
  156. Url::redirect($this->buildUrl($step));
  157. }
  158. }
  159.  
  160.  
  161. /**
  162.   * Gets the number (should be 0 or 1) of first step of the process
  163.   * @return int Usually 0 or 1, although in theory step 1000 can be the first step if so desired
  164.   */
  165. protected function firstStep()
  166. {
  167. return Sprout::iterableFirstKey($this->steps);
  168. }
  169.  
  170.  
  171. /**
  172.   * Check the user has access to a step
  173.   * @return void A redirect is performed if the user isn't ready for the step
  174.   */
  175. protected function checkStep($session, $step)
  176. {
  177. $session_step = (int) @$session['step'];
  178. if ($session_step < 1) $session_step = 1;
  179.  
  180. if ($session_step >= $step) return;
  181.  
  182. Url::redirect($this->buildUrl($session_step));
  183. }
  184.  
  185.  
  186. /**
  187.   * Instantiate the session and make sure the session key is useable
  188.   * @return array
  189.   */
  190. protected function &getSession() {
  191. $session = Session::instance();
  192. if (!isset($_SESSION[$this->session_key])) {
  193. $_SESSION[$this->session_key] = array();
  194. }
  195. return $_SESSION[$this->session_key];
  196. }
  197.  
  198.  
  199. /**
  200.   * @param int $step
  201.   * @param array $reqd Names of required fields, e.g. ['name', 'email']
  202.   * @param array $rules Each element is an array which is passed to {@see Validator::check},
  203.   * e.g. [['first_name', 'Validity::length', 0, 20], ['last_name', 'Validity::length', 0, 20]]
  204.   * @param mixed $valid a {@see Validator}, or null to create one on the fly. Specifying a Validator
  205.   * allows the addition of rules/errors before this method is called.
  206.   * @return void A redirect will occur if validation fails
  207.   */
  208. protected function validate($step, array $reqd, array $rules, Validator $valid = null)
  209. {
  210. $session = &self::getSession();
  211.  
  212. if (!$valid) {
  213. Validator::trim($_POST);
  214. $valid = new Validator($_POST);
  215. }
  216.  
  217. $valid->required($reqd);
  218.  
  219. foreach ($rules as $rule) {
  220. $name = Sprout::iterableFirstValue($rule);
  221. $session['data'][$name] = @$_POST[$name];
  222. call_user_func_array([$valid, 'check'], $rule);
  223. }
  224.  
  225. if ($valid->hasErrors()) {
  226. $session['field_errors'] = $valid->getFieldErrors();
  227. $valid->createNotifications();
  228. Url::redirect($this->buildUrl($step));
  229. }
  230. unset($session['field_errors']);
  231. }
  232.  
  233.  
  234. /**
  235.   * Builds a URL to the form at a particular step
  236.   * @param int $step
  237.   * @param bool $include_first Include /0 or /1 ending for the first step
  238.   * @return string the URL
  239.   */
  240. protected function buildUrl($step, $include_first = false)
  241. {
  242. $url = $this->route;
  243. if ($step != $this->firstStep() or $include_first) {
  244. $url .= "/{$step}";
  245. }
  246. return $url;
  247. }
  248.  
  249.  
  250. /**
  251.   * Do any final preparation / data massaging; then call saveData and finally redirect.
  252.   * This should generally be overridden.
  253.   */
  254. protected function save()
  255. {
  256. try {
  257. $id = $this->saveData($this->table);
  258.  
  259. // If copy-pasting this function, add post-insert stuff like emailing a thank you notice here
  260.  
  261. Url::redirect("{$this->route}/{$this->complete_function}");
  262.  
  263. } catch (QueryException $ex) {
  264. Notification::error('A database error occurred. Please contact us to resolve the issue.');
  265.  
  266. // Return to last step of the form
  267. $keys = array_keys($this->steps);
  268. Url::redirect($this->build_url(end($keys)));
  269. }
  270. }
  271.  
  272.  
  273. /**
  274.   * Save the data, e.g. into a database, after successful validation at the
  275.   * final step
  276.   * @param string $table The table to save the data in, e.g. 'members'
  277.   * @param array $sub_tables The subtables which should be used. The keys
  278.   * must match those used in the $session['data'] array. Each of the
  279.   * values is itself an array with one element. The 0-th value is the
  280.   * subtable to save the data in, and the 1st value is the column in
  281.   * that table which links to the core table,
  282.   * e.g. ['preferences' => ['member_prefs', 'member_id']]
  283.   * @param array $extra_fields Extra field values which should be set before
  284.   * performing the inserts. The keys are the table names, and each
  285.   * value is an array of column name to value mappings,
  286.   * e.g. ['members' => ['date_added' => Pdb::now(), ...]]
  287.   * @return int|null The insert id from the newly created record,
  288.   * or null if no table is specified
  289.   */
  290. protected function saveData($table, array $sub_tables = array(), array $extra_fields = array()) {
  291. $session = &self::getSession();
  292. $data = $session['data'];
  293.  
  294. if (IN_PRODUCTION) unset($_SESSION[$this->session_key]);
  295.  
  296. // Nothing can be saved in DB if table isn't specified
  297. if ($table == '') return null;
  298.  
  299. // Move data for subtables out of the data for the core table
  300. $sub_data = array();
  301. foreach ($sub_tables as $key => $config) {
  302. $sub_data[$key] = array();
  303.  
  304. // Transpose data, e.g. ['name'][1-5] => [1-5]['name']
  305. $values = $data[$key];
  306. foreach ($values as $field => $arr) {
  307. foreach ($arr as $num => $value) {
  308. $sub_data[$key][$num][$field] = $value;
  309. }
  310. }
  311. unset($data[$key]);
  312. }
  313.  
  314. // Insert data into core table
  315. $insert_data = array();
  316. foreach ($data as $field => $val) {
  317. $insert_data[$field] = $val;
  318. }
  319. if (isset($extra_fields[$table])) {
  320. foreach ($extra_fields[$table] as $field => $val) {
  321. $insert_data[$field] = $val;
  322. }
  323. }
  324. $insert_data['date_added'] = Pdb::now();
  325. $insert_data['date_modified'] = Pdb::now();
  326.  
  327. Pdb::transact();
  328. $id = Pdb::insert($table, $insert_data);
  329.  
  330. // Insert data into subtables
  331. foreach ($sub_tables as $key => $config) {
  332. list($sub_name, $link_col) = $config;
  333.  
  334. $insert_data = array($link_col => $id);
  335. foreach ($sub_data[$key] as $record) {
  336. foreach ($record as $field => $val) {
  337. $insert_data[$field] = $val;
  338. }
  339. if (isset($extra_fields[$sub_name])) {
  340. foreach ($extra_fields[$sub_name] as $field => $val) {
  341. $insert_data[$field] = $val;
  342. }
  343. }
  344. Pdb::insert($sub_name, $insert_data);
  345. }
  346. }
  347. Pdb::commit();
  348.  
  349. return $id;
  350. }
  351. }
  352.