diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..3f84682 --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,18 @@ +0.2: + - ENHANCEMENT Updated entities and added german translation for multiform + - ENHANCEMENT Making multiform module translatable + - ENHANCEMENT Added MultiFormStep->saveInto() to simulate Form->saveInto() + - ENHANCEMENT Made MultiForm->prev() do the same behaviour for saving data + - ENHANCEMENT Added MultiForm->getSavedSteps() + - BUGFIX Removing url_type which isnt very useful + - BUGFIX $this->form wasn't accessible on MultiFormStep + - ENHANCEMENT Correct use of parent::construct() so that fields, actions + - BUGFIX SQL injection possibility fix on MultiForm->getSessionRecordByID() + - BUGFIX Disable security token inherited from Form, which isn't required + - BUGFIX Made MultiFormPurgeTask greatly simplified, and workable + - ENHANCEMENT Allowed static $ignored_fields to be overloaded on subclass of MultiForm + - BUGFIX Use $nextStep->Link and $prevStep->Link() for prev() and next() on MultiForm + - API CHANGE Ticket #2562 - Cleaner instanciation of MultiForm subclass without having to call ->init() + +0.1: +- initial release \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e6bcfb1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +* Copyright (c) 2008, Silverstripe Ltd. +* All rights reserved. +* +* Redistribution and use in source and binary forms, with or without +* modification, are permitted provided that the following conditions are met: +* * Redistributions of source code must retain the above copyright +* notice, this list of conditions and the following disclaimer. +* * Redistributions in binary form must reproduce the above copyright +* notice, this list of conditions and the following disclaimer in the +* documentation and/or other materials provided with the distribution. +* * Neither the name of the nor the +* names of its contributors may be used to endorse or promote products +* derived from this software without specific prior written permission. +* +* THIS SOFTWARE IS PROVIDED BY Silverstripe Ltd. ``AS IS'' AND ANY +* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +* DISCLAIMED. IN NO EVENT SHALL Silverstripe Ltd. BE LIABLE FOR ANY +* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README b/README new file mode 100644 index 0000000..3412089 --- /dev/null +++ b/README @@ -0,0 +1,36 @@ +############################################### +SilverStripe MultiForm module +############################################### + +April 3rd, 2008 + +This module acts as a "wizard" for multiple form steps with automatic +session-saving of data, and versatile start/finish customizations. + +Maintainer Contact +----------------------------------------------- +Sean Harvey (Nickname: sharvey, halkyon) + + +Requirements +----------------------------------------------- +SilverStripe 2.2.2 or higher is required. + +Documentation +----------------------------------------------- +http://doc.silverstripe.com/doku.php?id=modules:multiform + +Installation Instructions +----------------------------------------------- +1) Copy the "multiform" directory into your main SilverStripe directory +2) run db/build?flush=1 +3) View documentation (link above) on how to model your forms into steps + +Usage Overview +----------------------------------------------- +If you ever add a new step, you must run db/build?flush=1, as the step requires +a class name in the database to store it's session state, and data. + +Known issues: +----------------------------------------------- +Please check http://open.silverstripe.com for known issues. \ No newline at end of file diff --git a/_config.php b/_config.php new file mode 100644 index 0000000..db1f70d --- /dev/null +++ b/_config.php @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/code/MultiForm.php b/code/MultiForm.php new file mode 100644 index 0000000..72cdc5b --- /dev/null +++ b/code/MultiForm.php @@ -0,0 +1,556 @@ + + * static $allowed_actions = array('next','prev'); + * + * + * @package multiform + */ +abstract class MultiForm extends Form { + + /** + * A session object stored in the database, to identify and store + * data for this MultiForm instance. + * + * @var MultiFormSession + */ + protected $session; + + /** + * Defines which subclass of {@link MultiFormStep} should be the first + * step in the multi-step process. + * + * @var string Classname of a {@link MultiFormStep} subclass + */ + protected static $start_step; + + /** + * Set the casting for these fields. + * + * @var array + */ + public static $casting = array( + 'CompletedStepCount' => 'Int', + 'TotalStepCount' => 'Int', + 'CompletedPercent' => 'Float' + ); + + /** + * These fields are ignored when saving the raw form data into session. + * This ensures only field data is saved, and nothing else that's useless + * or potentially dangerous. + * + * @var array + */ + public static $ignored_fields = array( + 'url', + 'executeForm', + 'MultiFormSessionID', + 'SecurityID' + ); + + /** + * Start the MultiForm instance. + * + * @param Controller instance $controller Controller this form is created on + * @param string $name The form name, typically the same as the method name + */ + public function __construct($controller, $name) { + + // Set up the session for this MultiForm instance + $this->setSession(); + + // Get the current step available (Note: either returns an existing + // step or creates a new one if none available) + $currentStep = $this->getCurrentStep(); + + // Set the step returned above as the current step + $this->setCurrentStep($currentStep); + + // Set the form of the step to this form instance + $currentStep->form = $this; + + // Set up the fields for the current step + $fields = $currentStep->getFields(); + + // Set up the actions for the current step + $actions = $this->actionsFor($currentStep); + + // Set up validation (if necessary) + // @todo find a better way instead of hardcoding a check for action_prev in order to prevent validation when hitting the back button + $validator = null; + if(empty($_REQUEST['action_prev'])) { + if($this->getCurrentStep()->getValidator()) { + $validator = $this->getCurrentStep()->getValidator(); + } + } + + // Give the fields, actions, and validation for the current step back to the parent Form class + parent::__construct($controller, $name, $fields, $actions, $validator); + + // Set a hidden field in our form with an encrypted hash to identify this session. + $this->fields->push(new HiddenField('MultiFormSessionID', false, $this->session->Hash)); + + // If there is saved data for the current step, we load it into the form it here + //(CAUTION: loadData() MUST unserialize first!) + if($currentStep->loadData()) { + $this->loadDataFrom($currentStep->loadData()); + } + + // Disable security token - we tie a form to a session ID instead + $this->disableSecurityToken(); + } + + /** + * Accessor method to $this->controller. + * + * @return Controller this MultiForm was instanciated on. + */ + public function getController() { + return $this->controller; + } + + /** + * Get the current step. + * + * If StepID has been set in the URL, we attempt to get that record + * by the ID. Otherwise, we check if there's a current step ID in + * our session record. Failing those cases, we assume that the form has + * just been started, and so we create the first step and return it. + * + * @return MultiFormStep subclass + */ + public function getCurrentStep() { + $startStepClass = $this->stat('start_step'); + + // Check if there was a start step defined on the subclass of MultiForm + if(!isset($startStepClass)) user_error('MultiForm::init(): Please define a $startStep on ' . $this->class, E_USER_ERROR); + + // Determine whether we use the current step, or create one if it doesn't exist + if(isset($_GET['StepID'])) { + $stepID = (int)$_GET['StepID']; + $currentStep = DataObject::get_one('MultiFormStep', "SessionID = {$this->session->ID} AND ID = {$stepID}"); + } elseif($this->session->CurrentStepID) { + $currentStep = $this->session->CurrentStep(); + } else { + $currentStep = new $startStepClass(); + $currentStep->SessionID = $this->session->ID; + $currentStep->write(); + } + + return $currentStep; + } + + /** + * Set the step passed in as the current step. + * + * @param MultiFormStep $step A subclass of MultiFormStep + * @return boolean The return value of write() + */ + protected function setCurrentStep($step) { + $this->session->CurrentStepID = $step->ID; + return $this->session->write(); + } + + /** + * Accessor method to $this->session. + * + * @return MultiFormSession + */ + function getSession() { + return $this->session; + } + + /** + * Set up the session. + * + * If MultiFormSessionID isn't set, we assume that this is a new + * multiform that requires a new session record to be created. + * + * @TODO Fix the fact you can continually refresh and create new records + * if MultiFormSessionID isn't set. + * + * @TODO Not sure if we should bake the session stuff directly into MultiForm. + * Perhaps it would be best dealt with on a separate class? + */ + protected function setSession() { + // If there's a MultiFormSessionID variable set, find that, otherwise create a new session + if(isset($_GET['MultiFormSessionID'])) { + $this->session = $this->getSessionRecord($_GET['MultiFormSessionID']); + } + + // If there was no session found, create a new one instead + if(!$this->session) { + $this->session = new MultiFormSession(); + $this->session->write(); + } + + // Create encrypted identification to the session instance if it doesn't exist + if(!$this->session->Hash) { + $this->session->Hash = sha1($this->session->ID . '-' . microtime()); + $this->session->write(); + } + } + + /** + * Return an instance of MultiFormSession. + * + * @param string $hash The unique, encrypted hash to identify the session + * @return MultiFormSession + */ + function getSessionRecord($hash) { + $SQL_hash = Convert::raw2sql($hash); + return DataObject::get_one('MultiFormSession', "Hash = '$SQL_hash' AND IsComplete = 0"); + } + + /** + * Get all steps saved in the database for the currently active session, + * in the order they were saved, oldest to newest (automatically ordered by ID). + * If you want a full chain of steps regardless if they've already been saved + * to the database, use {@link getAllStepsLinear()}. + * + * @return DataObjectSet|boolean A set of MultiFormStep subclasses + */ + function getSavedSteps() { + return DataObject::get( + 'MultiFormStep', + sprintf("SessionID = '%s'", + $this->session->ID + ) + ); + } + + /** + * Get a step which was previously saved to the database in the current session. + * Caution: This might cause unexpected behaviour if you have multiple steps + * in your chain with the same classname. + * + * @param string $className Classname of a {@link MultiFormStep} subclass + * @return MultiFormStep + */ + function getSavedStepByClass($className) { + return DataObject::get_one( + 'MultiFormStep', + sprintf("SessionID = '%s' AND ClassName = '%s'", + $this->session->ID, + Convert::raw2sql($className) + ) + ); + } + + /** + * Build a FieldSet of the FormAction fields for the given step. + * + * If the current step is the final step, we push in a submit button, which + * calls the action {@link finish()} to finalise the submission. Otherwise, + * we push in a next button which calls the action {@link next()} to determine + * where to go next in our step process, and save any form data collected. + * + * If there's a previous step (a step that has the current step as it's next + * step class), then we allow a previous button, which calls the previous action + * to determine which step to go back to. + * + * If there are any extra actions defined in MultiFormStep->getExtraActions() + * then that set of actions is appended to the end of the actions FieldSet we + * have created in this method. + * + * @param $currentStep Subclass of MultiFormStep + * @return FieldSet of FormAction objects + */ + function actionsFor($step) { + // Create default multi step actions (next, prev), and merge with extra actions, if any + $actions = new FieldSet(); + + // If the form is at final step, create a submit button to perform final actions + // The last step doesn't have a next button, so add that action to any step that isn't the final one + if($step->isFinalStep()) { + $actions->push(new FormAction('finish', _t('MultiForm.SUBMIT', 'Submit'))); + } else { + $actions->push(new FormAction('next', _t('MultiForm.NEXT', 'Next'))); + } + + // If there is a previous step defined, add the back button + if($step->getPreviousStep() && $step->canGoBack()) { + // If there is a next step, insert the action before the next action + if($step->getNextStep()) { + $actions->insertBefore(new FormAction('prev', _t('MultiForm.BACK', 'Back')), 'action_next'); + // Assume that this is the last step, insert the action before the finish action + } else { + $actions->insertBefore(new FormAction('prev', _t('MultiForm.BACK', 'Back')), 'action_finish'); + } + } + + // Merge any extra action fields defined on the step + $actions->merge($step->getExtraActions()); + + return $actions; + } + + /** + * Return a rendered version of this form, with a specific template. + * Looks through the step ancestory templates (MultiFormStep, current step + * subclass template) to see if one is available to render the form with. If + * any of those don't exist, look for a default Form template to render + * with instead. + * + * @return SSViewer object to render the template with + */ + function forTemplate() { + return $this->renderWith(array( + $this->getCurrentStep()->class, + 'MultiFormStep', + $this->class, + 'MultiForm', + 'Form' + )); + } + + /** + * This method saves the data on the final step, after submitting. + * It should always be overloaded with parent::finish($data, $form) + * so you can create your own functionality which handles saving + * of all the data collected through each step of the form. + * + * @param array $data The request data returned from the form + * @param object $form The form that the action was called on + */ + public function finish($data, $form) { + if(!$this->getCurrentStep()->isFinalStep()) { + Director::redirectBack(); + return false; + } + + // Save the form data for the current step + $this->save($data); + } + + /** + * Determine what to do when the next action is called. + * + * Saves the current step session data to the database, creates the + * new step based on getNextStep() of the current step (or fetches + * an existing one), resets the current step to the next step, + * then redirects to the newly set step. + * + * @param array $data The request data returned from the form + * @param object $form The form that the action was called on + */ + public function next($data, $form) { + // Get the next step class + $nextStepClass = $this->getCurrentStep()->getNextStep(); + + if(!$nextStepClass) { + Director::redirectBack(); + return false; + } + + // custom validation (use MultiFormStep->getValidator() for built-in functionality) + if(!$this->getCurrentStep()->validateStep($data, $form)) { + Director::redirectBack(); + return false; + } + + // Save the form data for the current step + $this->save($form->getData()); + + // Determine whether we can use a step already in the DB, or have to create a new one + if(!$nextStep = DataObject::get_one($nextStepClass, "SessionID = {$this->session->ID}")) { + $nextStep = new $nextStepClass(); + $nextStep->SessionID = $this->session->ID; + $nextStep->write(); + } + + // Set the next step found as the current step + $this->setCurrentStep($nextStep); + + // Redirect to the next step + Director::redirect($nextStep->Link()); + return; + } + + /** + * Determine what to do when the previous action is called. + * + * Retrieves the previous step class, finds the record for that + * class in the DB, and sets the current step to that step found. + * Finally, it redirects to that step. + * + * @param array $data The request data returned from the form + * @param object $form The form that the action was called on + */ + public function prev($data, $form) { + // Get the previous step class + $prevStepClass = $this->getCurrentStep()->getPreviousStep(); + + if(!$prevStepClass && !$this->getCurrentStep()->canGoBack()) { + Director::redirectBack(); + return false; + } + + // Save the form data for the current step + $this->save($form->getData()); + + // Get the previous step of the class instance returned from $currentStep->getPreviousStep() + $prevStep = DataObject::get_one($prevStepClass, "SessionID = {$this->session->ID}"); + + // Set the current step as the previous step + $this->setCurrentStep($prevStep); + + // Redirect to the previous step + Director::redirect($prevStep->Link()); + return; + } + + /** + * Save the raw data given back from the form into session. + * + * Take the submitted form data for the current step, removing + * any key => value pairs that shouldn't be saved, then saves + * the data into the session. + * + * @param array $data An array of data to save + */ + protected function save($data) { + $currentStep = $this->getCurrentStep(); + if(is_array($data)) { + foreach($data as $field => $value) { + if(in_array($field, $this->stat('ignored_fields'))) { + unset($data[$field]); + } + } + $currentStep->saveData($data); + } + return; + } + + // ############ Misc ############ + + /** + * Add the MultiFormSessionID variable to the URL on form submission. + * This is a means to persist the session, by adding it's identification + * to the URL, which ties it back to this MultiForm instance. + * + * @return string + */ + function FormAction() { + $action = parent::FormAction(); + $action .= (strpos($action, '?')) ? '&' : '?'; + $action .= "MultiFormSessionID={$this->session->Hash}"; + + return $action; + } + + /** + * Determine the steps to show in a linear fashion, starting from the + * first step. We run {@link getAllStepsRecursive} passing the steps found + * by reference to get a listing of the steps. + * + * @return DataObjectSet + */ + public function getAllStepsLinear() { + $stepsFound = new DataObjectSet(); + + $firstStep = DataObject::get_one($this->stat('start_step'), "SessionID = {$this->session->ID}"); + $templateData = array( + 'ID' => $firstStep->ID, + 'ClassName' => $firstStep->class, + 'Title' => $firstStep->title ? $firstStep->title : $firstStep->class, + 'SessionID' => $this->session->Hash, + 'LinkingMode' => ($firstStep->ID == $this->getCurrentStep()->ID) ? 'current' : 'link' + ); + $stepsFound->push(new ArrayData($templateData)); + + $this->getAllStepsRecursive($firstStep, $stepsFound); + + return $stepsFound; + } + + /** + * Recursively run through steps using the getNextStep() method on each step + * to determine what the next step is, gathering each step along the way. + * We stop on the last step, and return the results. + * If a step in the chain was already saved to the database in the current + * session, its used - otherwise a singleton of this step is used. + * Caution: Doesn't consider branching for steps which aren't in the database yet. + * + * @param $step Subclass of MultiFormStep to find the next step of + * @param $stepsFound $stepsFound DataObjectSet reference, the steps found to call back on + * @return DataObjectSet + */ + protected function getAllStepsRecursive($step, &$stepsFound) { + // Find the next step to the current step, the final step has no next step + if(!$step->isFinalStep()) { + if($step->getNextStep()) { + // Is this step in the DB? If it is, we use that + if($nextStep = $step->getNextStepFromDatabase()) { + $record = array( + 'ID' => $nextStep->ID, + 'ClassName' => $nextStep->class, + 'Title' => $nextStep->title ? $nextStep->title : $nextStep->class, + 'SessionID' => $this->session->Hash, + 'LinkingMode' => ($nextStep->ID == $this->getCurrentStep()->ID) ? 'current' : 'link' + ); + } else { + // If it's not in the DB, we use a singleton instance of it instead - this step hasn't been accessed yet + $nextStep = singleton($step->getNextStep()); + $record = array( + 'ClassName' => $nextStep->class, + 'Title' => $nextStep->title ? $nextStep->title : $nextStep->class + ); + } + // Add the array data, and do a callback + $stepsFound->push(new ArrayData($record)); + $this->getAllStepsRecursive($nextStep, $stepsFound); + } + // Once we've reached the final step, we just return what we've collected + } else { + return $stepsFound; + } + } + + /** + * Number of steps already completed (excluding currently started step). + * The way we determine a step is complete is to check if it has the Data + * field filled out with a serialized value, then we know that the user has + * clicked next on the given step, to proceed. + * + * @TODO Not sure if it's entirely appropriate to check if Data is set as a + * way to determine a step is "completed". + * + * @return int + */ + public function getCompletedStepCount() { + $steps = DataObject::get('MultiFormStep', "SessionID = {$this->session->ID} && Data IS NOT NULL"); + return $steps ? $steps->Count() : 0; + } + + /** + * Total number of steps in the shortest path (only counting straight path without any branching) + * The way we determine this is to check if each step has a next_step string variable set. If it's + * anything else (like an array, for defining multiple branches) then it gets counted as a single step. + * + * @return int + */ + public function getTotalStepCount() { + return $this->getAllStepsLinear() ? $this->getAllStepsLinear()->Count() : 0; + } + + /** + * Percentage of steps completed (excluding currently started step) + * + * @return float + */ + public function getCompletedPercent() { + return (float)$this->CompletedStepCount * 100 / $this->TotalStepCount; + } + +} + +?> \ No newline at end of file diff --git a/code/MultiFormObjectDecorator.php b/code/MultiFormObjectDecorator.php new file mode 100644 index 0000000..8168d95 --- /dev/null +++ b/code/MultiFormObjectDecorator.php @@ -0,0 +1,59 @@ +`.`MultiFormIsTemporary` = 1 + * + * @package multiform + */ +class MultiFormObjectDecorator extends DataObjectDecorator { + + public function updateDBFields() { + return array( + 'db' => array( + 'MultiFormIsTemporary' => 'Boolean', + ), + 'has_one' => array( + 'MultiFormSession' => 'MultiFormSession', + ) + ); + } + + public function augmentSQL(SQLQuery &$query) { + // If you're querying by ID, ignore the sub-site - this is a bit ugly... + if( + strpos($query->where[0], ".`ID` = ") === false + && strpos($query->where[0], ".ID = ") === false + && strpos($query->where[0], "ID = ") !== 0 + && !$this->wantsTemporary($query) + ) { + $query->where[] = "`{$query->from[0]}`.`MultiFormIsTemporary` = 0"; + } + } + + /** + * Determines if the current query is supposed + * to be exempt from the automatic filtering out + * of temporary records. + * + * @param SQLQuery $query + * @return boolean + */ + protected function wantsTemporary($query) { + foreach($query->where as $whereClause) { + if($whereClause == "`{$query->from[0]}`.`MultiFormIsTemporary` = 1") return true; + } + return false; + } + +} + +?> \ No newline at end of file diff --git a/code/MultiFormPurgeTask.php b/code/MultiFormPurgeTask.php new file mode 100644 index 0000000..0eca20e --- /dev/null +++ b/code/MultiFormPurgeTask.php @@ -0,0 +1,54 @@ +getExpiredSessions(); + $delCount = 0; + if($sessions) foreach($sessions as $session) { + if($session->delete()) $delCount++; + } + echo $delCount . ' session records deleted that were older than ' . self::$session_expiry_days . ' days.'; + } + + /** + * Return all MultiFormSession database records that are older than + * the days specified in $session_expiry_days + * + * @return DataObjectSet + */ + protected function getExpiredSessions() { + return DataObject::get( + 'MultiFormSession', + "DATEDIFF(NOW(), `MultiFormSession`.`Created`) > " . self::$session_expiry_days + ); + } + +} + +?> \ No newline at end of file diff --git a/code/MultiFormSession.php b/code/MultiFormSession.php new file mode 100644 index 0000000..9adafcd --- /dev/null +++ b/code/MultiFormSession.php @@ -0,0 +1,67 @@ + 'Varchar(40)', // cryptographic hash identification to this session + 'IsComplete' => 'Boolean' // flag to determine if this session is marked completed + ); + + static $has_one = array( + 'Submitter' => 'Member', + 'CurrentStep' => 'MultiFormStep' + ); + + static $has_many = array( + 'FormSteps' => 'MultiFormStep' + ); + + /** + * Mark this session as completed. + * + * This sets the flag "IsComplete" to true, + * and writes the session back. + */ + public function markCompleted() { + $this->IsComplete = 1; + $this->write(); + } + + /** + * These actions are performed when write() is called on this object. + */ + public function onBeforeWrite() { + // save submitter if a Member is logged in + $currentMember = Member::currentMember(); + if(!$this->SubmitterID && $currentMember) $this->SubmitterID = $currentMember->ID; + + parent::onBeforeWrite(); + } + + /** + * These actions are performed when delete() is called on this object. + */ + public function onBeforeDelete() { + // delete dependent form steps and relation + $steps = $this->FormSteps(); + if($steps) foreach($steps as $step) { + $steps->remove($step); // @TODO not sure if this is required (does delete() remove the relation too?) + $step->delete(); + } + + parent::onBeforeDelete(); + } + +} + +?> \ No newline at end of file diff --git a/code/MultiFormStep.php b/code/MultiFormStep.php new file mode 100644 index 0000000..1a8652d --- /dev/null +++ b/code/MultiFormStep.php @@ -0,0 +1,340 @@ + 'Text' // stores serialized maps with all session information + ); + + static $has_one = array( + 'Session' => 'MultiFormSession' + ); + + /** + * Centerpiece of the flow control for the form. + * + * If set to a string, you have a linear form flow + * If set to an array, you should use {@link getNextStep()} + * to enact flow control and branching to different form + * steps, most likely based on previously set session data + * (e.g. a checkbox field or a dropdown). + * + * @var array|string + */ + protected static $next_steps; + + /** + * Each {@link MultiForm} subclass needs at least + * one step which is marked as the "final" one + * and triggers the {@link MultiForm->finish()} + * method that wraps up the whole submission. + * + * @var boolean + */ + protected static $is_final_step = false; + + /** + * This variable determines whether a user can use + * the "back" action from this step. + * + * @TODO This does not check if the arbitrarily chosen step + * using the step indicator is actually a previous step, so + * unless you remove the link from the indicator template, or + * type in StepID=23 to the address bar you can still go back + * using the step indicator. + * + * @var boolean + */ + protected static $can_go_back = true; + + /** + * Title of this step. + * + * Used for the step indicator templates. + * + * @var string + */ + protected $title; + + /** + * Form class that this step is directly related to. + * + * @var MultiForm subclass + */ + protected $form; + + /** + * Form fields to be rendered with this step. + * (Form object is created in {@link MultiForm}. + * + * This function needs to be implemented on your + * subclasses of MultiFormStep. + * + * @return FieldSet + */ + public function getFields() { + user_error('Please implement getFields on your MultiFormStep subclass', E_USER_ERROR); + } + + /** + * Additional form actions to be added to this step. + * (Form object is created in {@link MultiForm}. + * + * Note: This is optional, and is to be implemented + * on your subclasses of MultiFormStep. + * + * @return FieldSet + */ + public function getExtraActions() { + return new FieldSet(); + } + + /** + * Get a validator specific to this form. + * The form is automatically validated in {@link Form->httpSubmission()}. + * + * @return Validator + */ + public function getValidator() { + return false; + } + + /** + * Accessor method for $this->title + * + * @return string Title of this step + */ + public function getTitle() { + return $this->title; + } + + /** + * Gets a direct link to this step (only works + * if you're allowed to skip steps, or this step + * has already been saved to the database + * for the current {@link MultiFormSession}). + * + * @return string Relative URL to this step + */ + public function Link() { + return Controller::curr()->Link() . '?MultiFormSessionID=' . $this->Session()->Hash; + } + + /** + * Unserialize stored session data and return it. + * This is used for loading data previously saved + * in session back into the form. + * + * You need to overload this method onto your own + * step if you require custom loading. An example + * would be selective loading specific fields, leaving + * others that are not required. + * + * @return array + */ + public function loadData() { + return unserialize($this->Data); + } + + /** + * Save the data for this step into session, serializing it first. + * + * To selectively save fields, instead of it all, this + * method would need to be overloaded on your step class. + * + * @param array $data The processed data from save() on {@link MultiForm} + */ + public function saveData($data) { + $this->Data = serialize($data); + $this->write(); + } + + /** + * Save the data on this step into an object, + * similiar to {@link Form->saveInto()} - by building + * a stub form from {@link getFields()}. This is necessary + * to trigger each {@link FormField->saveInto()} method + * individually, rather than assuming that all data + * serialized through {@link saveData()} can be saved + * as a simple value outside of the original FormField context. + * + * @param DataObject $obj + */ + public function saveInto($obj) { + $form = new Form( + new Controller(), + 'Form', + $this->getFields(), + new FieldSet() + ); + $form->loadDataFrom($this->loadData()); + $form->saveInto($obj); + return $obj; + } + + /** + * Custom validation for a step. In most cases, it should be sufficient + * to have built-in validation through the {@link Validator} class + * on the {@link getValidator()} method. + * + * Use {@link Form->sessionMessage()} to feed back validation messages + * to the user. Please don't redirect from this method, + * this is taken care of in {@link next()}. + * + * @param array $data Request data + * @param Form $form + * @return boolean Validation success + */ + public function validateStep($data, $form) { + return true; + } + + /** + * Returns the first value of $next_step + * + * @return String Classname of a {@link MultiFormStep} subclass + */ + public function getNextStep() { + $nextSteps = $this->stat('next_steps'); + + // Check if next_steps have been implemented properly if not the final step + if(!$this->isFinalStep()) { + if(!isset($nextSteps)) user_error('MultiFormStep->getNextStep(): Please define at least one $next_steps on ' . $this->class, E_USER_ERROR); + } + + if(is_string($nextSteps)) { + return $nextSteps; + } elseif(is_array($nextSteps) && count($nextSteps)) { + // custom flow control goes here + return $nextSteps[0]; + } else { + return false; + } + } + + /** + * Returns the next step to the current step in the database. + * + * This will only return something if you've previously visited + * the step ahead of the current step, and then gone back a step. + * + * @return MultiFormStep|boolean + */ + public function getNextStepFromDatabase() { + if($this->SessionID) { + $nextSteps = $this->stat('next_steps'); + if(is_string($nextSteps)) { + return DataObject::get_one($nextSteps, "SessionID = {$this->SessionID}"); + } elseif(is_array($nextSteps)) { + return DataObject::get_one($nextSteps[0], "SessionID = {$this->SessionID}"); + } else { + return false; + } + } + } + + /** + * Accessor method for self::$next_steps + * + * @return string|array + */ + public function getNextSteps() { + return $this->stat('next_steps'); + } + + /** + * Returns the previous step, if there is one. + * + * To determine if there is a previous step, we check the database to see if there's + * a previous step for this multi form session ID. + * + * @return String Classname of a {@link MultiFormStep} subclass + */ + public function getPreviousStep() { + $steps = DataObject::get('MultiFormStep', "SessionID = {$this->SessionID}", 'LastEdited DESC'); + if($steps) { + foreach($steps as $step) { + if($step->getNextStep()) { + if($step->getNextStep() == $this->class) { + return $step->class; + } + } + } + } + } + + /** + * Retrieves the previous step class record from the database. + * + * This will only return a record if you've previously been on the step. + * + * @return MultiFormStep subclass + */ + public function getPreviousStepFromDatabase() { + if($prevStepClass = $this->getPreviousStep()) { + return DataObject::get_one($prevStepClass, "SessionID = {$this->SessionID}"); + } + } + + /** + * Sets the form that this step is directly related to. + * + * @param MultiForm subclass $form + */ + public function setForm($form) { + $this->form = $form; + } + + /** + * @return Form + */ + public function getForm() { + return $this->form; + } + + // ##################### Utility #################### + + /** + * Determines whether the user is able to go back using the "action_back" + * form action, based on the boolean value of $can_go_back. + * + * @return boolean + */ + public function canGoBack() { + return $this->stat('can_go_back'); + } + + /** + * Determines whether this step is the final step in the multi-step process or not, + * based on the variable $is_final_step - which must be defined on at least one step. + * + * @return boolean + */ + public function isFinalStep() { + return $this->stat('is_final_step'); + } + + /** + * Determines whether the currently viewed step is the current step set in the session. + * This assumes you are checking isCurrentStep() against a data record of a MultiFormStep + * subclass, otherwise it doesn't work. An example of this is using a singleton instance - it won't + * work because there's no data. + * + * @return boolean + */ + public function isCurrentStep() { + return ($this->class == $this->Session()->CurrentStep()->class) ? true : false; + } + +} + +?> \ No newline at end of file diff --git a/lang/_manifest_exclude b/lang/_manifest_exclude new file mode 100644 index 0000000..e69de29 diff --git a/lang/de_DE.php b/lang/de_DE.php new file mode 100644 index 0000000..73232b4 --- /dev/null +++ b/lang/de_DE.php @@ -0,0 +1,31 @@ + \ No newline at end of file diff --git a/lang/en_US.php b/lang/en_US.php new file mode 100644 index 0000000..8adde85 --- /dev/null +++ b/lang/en_US.php @@ -0,0 +1,49 @@ + \ No newline at end of file diff --git a/templates/Includes/MultiFormProgressList.ss b/templates/Includes/MultiFormProgressList.ss new file mode 100644 index 0000000..ce98588 --- /dev/null +++ b/templates/Includes/MultiFormProgressList.ss @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/templates/Includes/MultiFormProgressPercent.ss b/templates/Includes/MultiFormProgressPercent.ss new file mode 100644 index 0000000..f2b064a --- /dev/null +++ b/templates/Includes/MultiFormProgressPercent.ss @@ -0,0 +1 @@ +

You've completed {$CompletedPercent.Nice}% ($CompletedStepCount/$TotalStepCount)

\ No newline at end of file diff --git a/tests/MultiFormSessionTest.php b/tests/MultiFormSessionTest.php new file mode 100644 index 0000000..c11d4d9 --- /dev/null +++ b/tests/MultiFormSessionTest.php @@ -0,0 +1,47 @@ +session + */ + function setUp() { + $this->session = new MultiFormSession(); + $this->session->write(); + } + + /** + * Test generation of a new session. + * + * @TODO Write some more advanced tests for MultiFormSession. + */ + function testSessionGeneration() { + $this->assertTrue($this->session->ID != 0); + $this->assertTrue($this->session->ID > 0); + } + + /** + * Test that a MemberID was set on MultiFormSession if + * a member is logged in. + */ + function testMemberLogging() { + $session = new MultiFormSession(); + $session->write(); + + if($memberID = Member::currentUserID()) { + $this->assertEquals($memberID, $session->SubmitterID); + } + } + + /** + * Delete the MultiFormSession record that we created. + */ + function tearDown() { + $this->session->delete(); + } + +} + +?> \ No newline at end of file diff --git a/tests/MultiFormTest.php b/tests/MultiFormTest.php new file mode 100644 index 0000000..c6eb1b5 --- /dev/null +++ b/tests/MultiFormTest.php @@ -0,0 +1,74 @@ +getSession() + */ + function setUp() { + $this->form = new MultiFormTestClass(new Controller(), 'Form'); + } + + /** + * Tests initialising a new instance of a test class. + * + * @TODO Write some decent tests! The current assertions are very basic, and are + * nowhere near touching on the more advanced concepts of MultiForm, such + * as the form actions (prev/next), session handling, and step handling + * through {@link MultiFormStep->getPreviousStep()} and + * {@link MultiFormStep->getNextStep()} for example. + */ + function testInitialisingForm() { + $this->assertTrue(is_numeric($this->form->getCurrentStep()->ID) && ($this->form->getCurrentStep()->ID > 0)); + $this->assertTrue(is_numeric($this->form->getSession()->ID) && ($this->form->getSession()->ID > 0)); + $this->assertEquals('MultiFormTestStepOne', $this->form->getStartStep()); + } + + /** + * Test that the 2nd step is correct to what we expect it to be. + */ + function testSecondStep() { + $this->assertEquals('MultiFormTestStepTwo', $this->form->getCurrentStep()->getNextStep()); + } + + /** + * Test that the amount of steps we have has been calculated correctly. + */ + function testTotalStepCount() { + $this->assertEquals(3, $this->form->getAllStepsLinear()->Count()); + } + + /** + * Remove the session data that was created. Note: This should delete all the + * dependencies such as MultiFormStep instances that are related directly to + * this session. These directives can be found on {@link MultiFormSession->onBeforeWrite()} + */ + function tearDown() { + $this->form->getSession()->delete(); + } + +} + +?> \ No newline at end of file diff --git a/tests/code/MultiFormTestClass.php b/tests/code/MultiFormTestClass.php new file mode 100644 index 0000000..220295f --- /dev/null +++ b/tests/code/MultiFormTestClass.php @@ -0,0 +1,17 @@ +stat('start_step'); + } + +} + +?> \ No newline at end of file diff --git a/tests/code/MultiFormTestStepOne.php b/tests/code/MultiFormTestStepOne.php new file mode 100644 index 0000000..aa7463e --- /dev/null +++ b/tests/code/MultiFormTestStepOne.php @@ -0,0 +1,17 @@ + \ No newline at end of file diff --git a/tests/code/MultiFormTestStepThree.php b/tests/code/MultiFormTestStepThree.php new file mode 100644 index 0000000..ef659e2 --- /dev/null +++ b/tests/code/MultiFormTestStepThree.php @@ -0,0 +1,15 @@ + \ No newline at end of file diff --git a/tests/code/MultiFormTestStepTwo.php b/tests/code/MultiFormTestStepTwo.php new file mode 100644 index 0000000..d4a3366 --- /dev/null +++ b/tests/code/MultiFormTestStepTwo.php @@ -0,0 +1,15 @@ + \ No newline at end of file