Completed
Push — master ( 619774...d2780c )
by Daniel
03:51
created

MultiForm   D

Complexity

Total Complexity 69

Size/Duplication

Total Lines 653
Duplicated Lines 1.53 %

Coupling/Cohesion

Components 2
Dependencies 13
Metric Value
wmc 69
lcom 2
cbo 13
dl 10
loc 653
rs 4.5914

24 Methods

Rating   Name   Duplication   Size   Complexity  
B __construct() 0 62 7
A getController() 0 3 1
C getCurrentStep() 0 29 7
A setCurrentStep() 0 6 1
A getSession() 0 3 1
A setSession() 0 14 3
A setCurrentSessionHash() 0 4 1
A getCurrentSession() 0 16 3
A getSavedSteps() 0 5 2
A getSavedStepByClass() 0 9 1
B actionsFor() 0 28 6
A forTemplate() 0 13 1
A finish() 5 16 3
B next() 5 37 4
A prev() 0 21 3
A save() 0 12 4
A FormAction() 0 7 2
A getDisplayLink() 0 3 2
A setDisplayLink() 0 3 1
A getAllStepsLinear() 0 16 4
C getAllStepsRecursive() 0 31 7
A getCompletedStepCount() 0 4 2
A getTotalStepCount() 0 3 2
A getCompletedPercent() 0 3 1

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complex Class

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like MultiForm often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use MultiForm, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * MultiForm manages the loading of single form steps, and acts as a state
5
 * machine that connects to a {@link MultiFormSession} object as a persistence
6
 * layer.
7
 *
8
 * CAUTION: If you're using controller permission control,
9
 * you have to allow the following methods:
10
 *
11
 * <code>
12
 * private static $allowed_actions = array('next','prev');
13
 * </code>
14
 *
15
 * @package multiform
16
 */
17
abstract class MultiForm extends Form {
1 ignored issue
show
Coding Style Compatibility introduced by
PSR1 recommends that each class must be in a namespace of at least one level to avoid collisions.

You can fix this by adding a namespace to your class:

namespace YourVendor;

class YourClass { }

When choosing a vendor namespace, try to pick something that is not too generic to avoid conflicts with other libraries.

Loading history...
Coding Style introduced by
As per PSR2, the opening brace for this class should be on a new line.
Loading history...
18
19
	/**
20
	 * A session object stored in the database, to identify and store
21
	 * data for this MultiForm instance.
22
	 *
23
	 * @var MultiFormSession
24
	 */
25
	protected $session;
26
27
	/**
28
	 * The current encrypted MultiFormSession identification.
29
	 *
30
	 * @var string
31
	 */
32
	protected $currentSessionHash;
33
34
	/**
35
	 * Defines which subclass of {@link MultiFormStep} should be the first
36
	 * step in the multi-step process.
37
	 *
38
	 * @var string Classname of a {@link MultiFormStep} subclass
39
	 */
40
	public static $start_step;
41
42
	/**
43
	 * Set the casting for these fields.
44
	 *
45
	 * @var array
46
	 */
47
	private static $casting = array(
0 ignored issues
show
Comprehensibility introduced by
Consider using a different property name as you override a private property of the parent class.
Loading history...
Unused Code introduced by
The property $casting is not used and could be removed.

This check marks private properties in classes that are never used. Those properties can be removed.

Loading history...
48
		'CompletedStepCount' => 'Int',
49
		'TotalStepCount' => 'Int',
50
		'CompletedPercent' => 'Float'
51
	);
52
53
	/**
54
	 * These fields are ignored when saving the raw form data into session.
55
	 * This ensures only field data is saved, and nothing else that's useless
56
	 * or potentially dangerous.
57
	 *
58
	 * @var array
59
	 */
60
	public static $ignored_fields = array(
61
		'url',
62
		'executeForm',
63
		'MultiFormSessionID',
64
		'SecurityID'
65
	);
66
67
	/**
68
	 * Any of the actions defined in this variable are exempt from
69
	 * being validated.
70
	 *
71
	 * This is most useful for the "Back" (action_prev) action, as
72
	 * you typically don't validate the form when the user is going
73
	 * back a step.
74
	 *
75
	 * @var array
76
	 */
77
	public static $actions_exempt_from_validation = array(
78
		'action_prev'
79
	);
80
81
	/**
82
	 * @var string
83
	 */
84
	protected $displayLink;
85
86
	/**
87
	 * Flag which is being used in getAllStepsRecursive() to allow adding the completed flag on the steps
88
	 *
89
	 * @var boolean
90
	 */
91
	protected $currentStepHasBeenFound = false;
92
93
	/**
94
	 * Start the MultiForm instance.
95
	 *
96
	 * @param Controller instance $controller Controller this form is created on
97
	 * @param string $name The form name, typically the same as the method name
98
	 */
99
	public function __construct($controller, $name) {
0 ignored issues
show
Coding Style introduced by
__construct uses the super-global variable $_REQUEST which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
100
		// First set the controller and name manually so they are available for
101
		// field construction.
102
		$this->controller = $controller;
0 ignored issues
show
Documentation Bug introduced by
It seems like $controller of type object<instance> is incompatible with the declared type object<Controller>|null of property $controller.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
103
		$this->name       = $name;
104
105
		// Set up the session for this MultiForm instance
106
		$this->setSession();
107
108
		// Get the current step available (Note: either returns an existing
109
		// step or creates a new one if none available)
110
		$currentStep = $this->getCurrentStep();
111
112
		// Set the step returned above as the current step
113
		$this->setCurrentStep($currentStep);
114
115
		// Set the form of the step to this form instance
116
		$currentStep->setForm($this);
117
118
		// Set up the fields for the current step
119
		$fields = $currentStep->getFields();
120
121
		// Set up the actions for the current step
122
		$actions = $this->actionsFor($currentStep);
123
124
		// Set up validation (if necessary)
125
		$validator = null;
126
		$applyValidation = true;
127
128
		$actionNames = static::$actions_exempt_from_validation;
129
130
		if( $actionNames ) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $actionNames of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
131
			foreach( $actionNames as $exemptAction) {
132
				if(!empty($_REQUEST[$exemptAction])) {
133
					$applyValidation = false;
134
					break;
135
				}
136
			}
137
		}
138
139
		// Apply validation if the current step requires validation (is not exempt)
140
		if($applyValidation) {
141
			if($currentStep->getValidator()) {
142
				$validator = $currentStep->getValidator();
143
			}
144
		}
145
146
		// Give the fields, actions, and validation for the current step back to the parent Form class
147
		parent::__construct($controller, $name, $fields, $actions, $validator);
0 ignored issues
show
Bug introduced by
It seems like $fields defined by $currentStep->getFields() on line 119 can be null; however, Form::__construct() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
Bug introduced by
It seems like $validator defined by $currentStep->getValidator() on line 142 can also be of type boolean; however, Form::__construct() does only seem to accept object<Validator>|null, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
148
149
		// Set a hidden field in our form with an encrypted hash to identify this session.
150
		$this->fields->push(new HiddenField('MultiFormSessionID', false, $this->session->Hash));
0 ignored issues
show
Documentation introduced by
The property Hash does not exist on object<MultiFormSession>. Since you implemented __get, maybe consider adding a @property annotation.

Since your code implements the magic getter _get, this function will be called for any read access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

If the property has read access only, you can use the @property-read annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
Documentation introduced by
false is of type boolean, but the function expects a null|string.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
151
152
		// If there is saved data for the current step, we load it into the form it here
153
		//(CAUTION: loadData() MUST unserialize first!)
0 ignored issues
show
Unused Code Comprehensibility introduced by
43% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
154
		if($data = $currentStep->loadData()) {
155
			$this->loadDataFrom($data);
156
		}
157
158
		// Disable security token - we tie a form to a session ID instead
159
		$this->disableSecurityToken();
160
	}
161
162
	/**
163
	 * Accessor method to $this->controller.
164
	 *
165
	 * @return Controller this MultiForm was instanciated on.
166
	 */
167
	public function getController() {
168
		return $this->controller;
169
	}
170
171
	/**
172
	 * Get the current step.
173
	 *
174
	 * If StepID has been set in the URL, we attempt to get that record
175
	 * by the ID. Otherwise, we check if there's a current step ID in
176
	 * our session record. Failing those cases, we assume that the form has
177
	 * just been started, and so we create the first step and return it.
178
	 *
179
	 * @return MultiFormStep subclass
180
	 */
181
	public function getCurrentStep() {
0 ignored issues
show
Coding Style introduced by
getCurrentStep uses the super-global variable $_GET which is generally not recommended.

Instead of super-globals, we recommend to explicitly inject the dependencies of your class. This makes your code less dependent on global state and it becomes generally more testable:

// Bad
class Router
{
    public function generate($path)
    {
        return $_SERVER['HOST'].$path;
    }
}

// Better
class Router
{
    private $host;

    public function __construct($host)
    {
        $this->host = $host;
    }

    public function generate($path)
    {
        return $this->host.$path;
    }
}

class Controller
{
    public function myAction(Request $request)
    {
        // Instead of
        $page = isset($_GET['page']) ? intval($_GET['page']) : 1;

        // Better (assuming you use the Symfony2 request)
        $page = $request->query->get('page', 1);
    }
}
Loading history...
182
		$startStepClass = static::$start_step;
183
184
		// Check if there was a start step defined on the subclass of MultiForm
185
		if(!isset($startStepClass)) user_error('MultiForm::init(): Please define a $start_step on ' . $this->class, E_USER_ERROR);
186
187
		// Determine whether we use the current step, or create one if it doesn't exist
188
		$currentStep = null;
189
		if(isset($_GET['StepID'])) {
190
			$stepID = (int)$_GET['StepID'];
191
			$currentStep = DataObject::get_one('MultiFormStep', "\"SessionID\" = {$this->session->ID} AND \"ID\" = {$stepID}");
192
		} elseif($this->session->CurrentStepID) {
0 ignored issues
show
Documentation introduced by
The property CurrentStepID does not exist on object<MultiFormSession>. Since you implemented __get, maybe consider adding a @property annotation.

Since your code implements the magic getter _get, this function will be called for any read access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

If the property has read access only, you can use the @property-read annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
193
			$currentStep = $this->session->CurrentStep();
0 ignored issues
show
Documentation Bug introduced by
The method CurrentStep does not exist on object<MultiFormSession>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
194
		}
195
196
		// Always fall back to creating a new step (in case the session or request data is invalid)
197
		if(!$currentStep || !$currentStep->ID) {
198
			$currentStep = Object::create($startStepClass);
199
			$currentStep->SessionID = $this->session->ID;
0 ignored issues
show
Bug introduced by
The property SessionID does not seem to exist. Did you mean session?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
200
			$currentStep->write();
0 ignored issues
show
Documentation Bug introduced by
The method write does not exist on object<MultiForm>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
201
			$this->session->CurrentStepID = $currentStep->ID;
0 ignored issues
show
Documentation introduced by
The property CurrentStepID does not exist on object<MultiFormSession>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
Documentation introduced by
The property ID does not exist on object<MultiForm>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
202
			$this->session->write();
203
			$this->session->flushCache();
204
		}
205
206
		if($currentStep) $currentStep->setForm($this);
207
208
		return $currentStep;
209
	}
210
211
	/**
212
	 * Set the step passed in as the current step.
213
	 *
214
	 * @param MultiFormStep $step A subclass of MultiFormStep
215
	 * @return boolean The return value of write()
216
	 */
217
	protected function setCurrentStep($step) {
218
		$this->session->CurrentStepID = $step->ID;
0 ignored issues
show
Documentation introduced by
The property CurrentStepID does not exist on object<MultiFormSession>. Since you implemented __set, maybe consider adding a @property annotation.

Since your code implements the magic setter _set, this function will be called for any write access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

Since the property has write access only, you can use the @property-write annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
219
		$step->setForm($this);
220
221
		return $this->session->write();
222
	}
223
224
	/**
225
	 * Accessor method to $this->session.
226
	 *
227
	 * @return MultiFormSession
228
	 */
229
	public function getSession() {
230
		return $this->session;
231
	}
232
233
	/**
234
	 * Set up the session.
235
	 *
236
	 * If MultiFormSessionID isn't set, we assume that this is a new
237
	 * multiform that requires a new session record to be created.
238
	 *
239
	 * @TODO Fix the fact you can continually refresh and create new records
240
	 * if MultiFormSessionID isn't set.
241
	 *
242
	 * @TODO Not sure if we should bake the session stuff directly into MultiForm.
243
	 * Perhaps it would be best dealt with on a separate class?
244
	 */
245
	protected function setSession() {
246
		$this->session = $this->getCurrentSession();
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->getCurrentSession() can also be of type boolean. However, the property $session is declared as type object<MultiFormSession>. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
247
248
		// If there was no session found, create a new one instead
249
		if(!$this->session) {
250
			$this->session = new MultiFormSession();
251
		}
252
253
		// Create encrypted identification to the session instance if it doesn't exist
254
		if(!$this->session->Hash) {
255
			$this->session->Hash = sha1($this->session->ID . '-' . microtime());
256
			$this->session->write();
257
		}
258
	}
259
260
	/**
261
	 * Set the currently used encrypted hash to identify
262
	 * the MultiFormSession.
263
	 *
264
	 * @param string $hash Encrypted identification to session
265
	 */
266
	public function setCurrentSessionHash($hash) {
267
		$this->currentSessionHash = $hash;
268
		$this->setSession();
269
	}
270
271
	/**
272
	 * Return the currently used {@link MultiFormSession}
273
	 * @return MultiFormSession|boolean FALSE
274
	 */
275
	public function getCurrentSession() {
276
		if(!$this->currentSessionHash) {
277
			$this->currentSessionHash = $this->controller->request->getVar('MultiFormSessionID');
278
279
			if(!$this->currentSessionHash) {
280
				return false;
281
			}
282
		}
283
284
		$this->session = MultiFormSession::get()->filter(array(
285
			"Hash" => $this->currentSessionHash,
286
			"IsComplete" => 0
287
		))->first();
288
289
		return $this->session;
290
	}
291
292
	/**
293
	 * Get all steps saved in the database for the currently active session,
294
	 * in the order they were saved, oldest to newest (automatically ordered by ID).
295
	 * If you want a full chain of steps regardless if they've already been saved
296
	 * to the database, use {@link getAllStepsLinear()}.
297
	 *
298
	 * @param String $filter SQL WHERE statement
299
	 * @return DataObjectSet|boolean A set of MultiFormStep subclasses
300
	 */
301
	function getSavedSteps($filter = null) {
0 ignored issues
show
Best Practice introduced by
It is generally recommended to explicitly declare the visibility for methods.

Adding explicit visibility (private, protected, or public) is generally recommend to communicate to other developers how, and from where this method is intended to be used.

Loading history...
302
		$filter .= ($filter) ? ' AND ' : '';
303
		$filter .= sprintf("\"SessionID\" = '%s'", $this->session->ID);
304
		return DataObject::get('MultiFormStep', $filter);
305
	}
306
307
	/**
308
	 * Get a step which was previously saved to the database in the current session.
309
	 * Caution: This might cause unexpected behaviour if you have multiple steps
310
	 * in your chain with the same classname.
311
	 *
312
	 * @param string $className Classname of a {@link MultiFormStep} subclass
313
	 * @return MultiFormStep
314
	 */
315
	function getSavedStepByClass($className) {
0 ignored issues
show
Best Practice introduced by
It is generally recommended to explicitly declare the visibility for methods.

Adding explicit visibility (private, protected, or public) is generally recommend to communicate to other developers how, and from where this method is intended to be used.

Loading history...
316
		return DataObject::get_one(
317
			'MultiFormStep',
318
			sprintf("\"SessionID\" = '%s' AND \"ClassName\" = '%s'",
319
				$this->session->ID,
320
				Convert::raw2sql($className)
321
			)
322
		);
323
	}
324
325
	/**
326
	 * Build a FieldList of the FormAction fields for the given step.
327
	 *
328
	 * If the current step is the final step, we push in a submit button, which
329
	 * calls the action {@link finish()} to finalise the submission. Otherwise,
330
	 * we push in a next button which calls the action {@link next()} to determine
331
	 * where to go next in our step process, and save any form data collected.
332
	 *
333
	 * If there's a previous step (a step that has the current step as it's next
334
	 * step class), then we allow a previous button, which calls the previous action
335
	 * to determine which step to go back to.
336
	 *
337
	 * If there are any extra actions defined in MultiFormStep->getExtraActions()
338
	 * then that set of actions is appended to the end of the actions FieldSet we
339
	 * have created in this method.
340
	 *
341
	 * @param $currentStep Subclass of MultiFormStep
342
	 * @return FieldList of FormAction objects
343
	 */
344
	function actionsFor($step) {
0 ignored issues
show
Best Practice introduced by
It is generally recommended to explicitly declare the visibility for methods.

Adding explicit visibility (private, protected, or public) is generally recommend to communicate to other developers how, and from where this method is intended to be used.

Loading history...
345
		// Create default multi step actions (next, prev), and merge with extra actions, if any
346
		$actions = (class_exists('FieldList')) ? new FieldList() : new FieldSet();
0 ignored issues
show
Bug Compatibility introduced by
The expression class_exists('FieldList'...st() : new \FieldSet(); of type FieldList|FieldSet adds the type FieldSet to the return on line 370 which is incompatible with the return type documented by MultiForm::actionsFor of type FieldList.
Loading history...
347
348
		// If the form is at final step, create a submit button to perform final actions
349
		// The last step doesn't have a next button, so add that action to any step that isn't the final one
350
		if($step->isFinalStep()) {
351
			$actions->push(new FormAction('finish', $step->getSubmitText()));
352
		} else {
353
			$actions->push(new FormAction('next', $step->getNextText()));
354
		}
355
356
		// If there is a previous step defined, add the back button
357
		if($step->getPreviousStep() && $step->canGoBack()) {
358
			// If there is a next step, insert the action before the next action
359
			if($step->getNextStep()) {
360
				$actions->insertBefore(new FormAction('prev', $step->getPrevText()), 'action_next');
0 ignored issues
show
Documentation introduced by
'action_next' is of type string, but the function expects a object<FormField>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
361
			// Assume that this is the last step, insert the action before the finish action
362
			} else {
363
				$actions->insertBefore(new FormAction('prev', $step->getPrevText()), 'action_finish');
0 ignored issues
show
Documentation introduced by
'action_finish' is of type string, but the function expects a object<FormField>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
364
			}
365
		}
366
367
		// Merge any extra action fields defined on the step
368
		$actions->merge($step->getExtraActions());
369
370
		return $actions;
371
	}
372
373
	/**
374
	 * Return a rendered version of this form, with a specific template.
375
	 * Looks through the step ancestory templates (MultiFormStep, current step
376
	 * subclass template) to see if one is available to render the form with. If
377
	 * any of those don't exist, look for a default Form template to render
378
	 * with instead.
379
	 *
380
	 * @return SSViewer object to render the template with
381
	 */
382
	function forTemplate() {
0 ignored issues
show
Best Practice introduced by
It is generally recommended to explicitly declare the visibility for methods.

Adding explicit visibility (private, protected, or public) is generally recommend to communicate to other developers how, and from where this method is intended to be used.

Loading history...
383
		$return = $this->renderWith(array(
384
			$this->getCurrentStep()->class,
385
			'MultiFormStep',
386
			$this->class,
387
			'MultiForm',
388
			'Form'
389
		));
390
391
		$this->clearMessage();
392
393
		return $return;
394
	}
395
396
	/**
397
	 * This method saves the data on the final step, after submitting.
398
	 * It should always be overloaded with parent::finish($data, $form)
399
	 * so you can create your own functionality which handles saving
400
	 * of all the data collected through each step of the form.
401
	 *
402
	 * @param array $data The request data returned from the form
403
	 * @param object $form The form that the action was called on
404
	 */
405
	public function finish($data, $form) {
406
		// Save the form data for the current step
407
		$this->save($data);
408
409
		if(!$this->getCurrentStep()->isFinalStep()) {
410
			$this->controller->redirectBack();
411
			return false;
412
		}
413
414 View Code Duplication
		if(!$this->getCurrentStep()->validateStep($data, $form)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
415
			Session::set("FormInfo.{$form->FormName()}.data", $form->getData());
416
			$this->controller->redirectBack();
417
			return false;
418
		}
419
420
	}
421
422
	/**
423
	 * Determine what to do when the next action is called.
424
	 *
425
	 * Saves the current step session data to the database, creates the
426
	 * new step based on getNextStep() of the current step (or fetches
427
	 * an existing one), resets the current step to the next step,
428
	 * then redirects to the newly set step.
429
	 *
430
	 * @param array $data The request data returned from the form
431
	 * @param object $form The form that the action was called on
432
	 */
433
	public function next($data, $form) {
434
		// Save the form data for the current step
435
		$this->save($form->getData());
436
437
		// Get the next step class
438
		$nextStepClass = $this->getCurrentStep()->getNextStep();
439
440
		if(!$nextStepClass) {
441
			$this->controller->redirectBack();
442
			return false;
443
		}
444
445
		// Perform custom step validation (use MultiFormStep->getValidator() for
446
		// built-in functionality). The data needs to be manually saved on error
447
		// so the form is re-populated.
448 View Code Duplication
		if(!$this->getCurrentStep()->validateStep($data, $form)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
449
			Session::set("FormInfo.{$form->FormName()}.data", $form->getData());
450
			$this->controller->redirectBack();
451
			return false;
452
		}
453
454
		// validation succeeded so we reset it to remove errors and messages
455
		$this->resetValidation();
456
457
		// Determine whether we can use a step already in the DB, or have to create a new one
458
		if(!$nextStep = DataObject::get_one($nextStepClass, "\"SessionID\" = {$this->session->ID}")) {
459
			$nextStep = Object::create($nextStepClass);
460
			$nextStep->SessionID = $this->session->ID;
0 ignored issues
show
Bug introduced by
The property SessionID does not seem to exist. Did you mean session?

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
461
			$nextStep->write();
0 ignored issues
show
Documentation Bug introduced by
The method write does not exist on object<MultiForm>? Since you implemented __call, maybe consider adding a @method annotation.

If you implement __call and you know which methods are available, you can improve IDE auto-completion and static analysis by adding a @method annotation to the class.

This is often the case, when __call is implemented by a parent class and only the child class knows which methods exist:

class ParentClass {
    private $data = array();

    public function __call($method, array $args) {
        if (0 === strpos($method, 'get')) {
            return $this->data[strtolower(substr($method, 3))];
        }

        throw new \LogicException(sprintf('Unsupported method: %s', $method));
    }
}

/**
 * If this class knows which fields exist, you can specify the methods here:
 *
 * @method string getName()
 */
class SomeClass extends ParentClass { }
Loading history...
462
		}
463
464
		// Set the next step found as the current step
465
		$this->setCurrentStep($nextStep);
0 ignored issues
show
Documentation introduced by
$nextStep is of type this<MultiForm>|object<DataObject>, but the function expects a object<MultiFormStep>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
466
467
		// Redirect to the next step
468
		$this->controller->redirect($nextStep->Link());
469
	}
470
471
	/**
472
	 * Determine what to do when the previous action is called.
473
	 *
474
	 * Retrieves the previous step class, finds the record for that
475
	 * class in the DB, and sets the current step to that step found.
476
	 * Finally, it redirects to that step.
477
	 *
478
	 * @param array $data The request data returned from the form
479
	 * @param object $form The form that the action was called on
480
	 */
481
	public function prev($data, $form) {
0 ignored issues
show
Unused Code introduced by
The parameter $data is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
482
		// Save the form data for the current step
483
		$this->save($form->getData());
484
485
		// Get the previous step class
486
		$prevStepClass = $this->getCurrentStep()->getPreviousStep();
487
488
		if(!$prevStepClass && !$this->getCurrentStep()->canGoBack()) {
489
			$this->controller->redirectBack();
490
			return false;
491
		}
492
493
		// Get the previous step of the class instance returned from $currentStep->getPreviousStep()
494
		$prevStep = DataObject::get_one($prevStepClass, "\"SessionID\" = {$this->session->ID}");
495
496
		// Set the current step as the previous step
497
		$this->setCurrentStep($prevStep);
0 ignored issues
show
Compatibility introduced by
$prevStep of type object<DataObject> is not a sub-type of object<MultiFormStep>. It seems like you assume a child class of the class DataObject to be always present.

This check looks for parameters that are defined as one type in their type hint or doc comment but seem to be used as a narrower type, i.e an implementation of an interface or a subclass.

Consider changing the type of the parameter or doing an instanceof check before assuming your parameter is of the expected type.

Loading history...
498
499
		// Redirect to the previous step
500
		$this->controller->redirect($prevStep->Link());
501
	}
502
503
	/**
504
	 * Save the raw data given back from the form into session.
505
	 *
506
	 * Take the submitted form data for the current step, removing
507
	 * any key => value pairs that shouldn't be saved, then saves
508
	 * the data into the session.
509
	 *
510
	 * @param array $data An array of data to save
511
	 */
512
	protected function save($data) {
513
		$currentStep = $this->getCurrentStep();
514
		if(is_array($data)) {
515
			foreach($data as $field => $value) {
516
				if(in_array($field, static::$ignored_fields)) {
517
					unset($data[$field]);
518
				}
519
			}
520
			$currentStep->saveData($data);
521
		}
522
		return;
523
	}
524
525
	// ############ Misc ############
526
527
	/**
528
	 * Add the MultiFormSessionID variable to the URL on form submission.
529
	 * This is a means to persist the session, by adding it's identification
530
	 * to the URL, which ties it back to this MultiForm instance.
531
	 *
532
	 * @return string
533
	 */
534
	function FormAction() {
0 ignored issues
show
Best Practice introduced by
It is generally recommended to explicitly declare the visibility for methods.

Adding explicit visibility (private, protected, or public) is generally recommend to communicate to other developers how, and from where this method is intended to be used.

Loading history...
535
		$action = parent::FormAction();
536
		$action .= (strpos($action, '?')) ? '&amp;' : '?';
537
		$action .= "MultiFormSessionID={$this->session->Hash}";
0 ignored issues
show
Documentation introduced by
The property Hash does not exist on object<MultiFormSession>. Since you implemented __get, maybe consider adding a @property annotation.

Since your code implements the magic getter _get, this function will be called for any read access on an undefined variable. You can add the @property annotation to your class or interface to document the existence of this variable.

<?php

/**
 * @property int $x
 * @property int $y
 * @property string $text
 */
class MyLabel
{
    private $properties;

    private $allowedProperties = array('x', 'y', 'text');

    public function __get($name)
    {
        if (isset($properties[$name]) && in_array($name, $this->allowedProperties)) {
            return $properties[$name];
        } else {
            return null;
        }
    }

    public function __set($name, $value)
    {
        if (in_array($name, $this->allowedProperties)) {
            $properties[$name] = $value;
        } else {
            throw new \LogicException("Property $name is not defined.");
        }
    }

}

If the property has read access only, you can use the @property-read annotation instead.

Of course, you may also just have mistyped another name, in which case you should fix the error.

See also the PhpDoc documentation for @property.

Loading history...
538
539
		return $action;
540
	}
541
542
	/**
543
	 * Returns the link to the page where the form is displayed. The user is
544
	 * redirected to this link with a session param after each step is
545
	 * submitted.
546
	 *
547
	 * @return string
548
	 */
549
	public function getDisplayLink() {
550
		return $this->displayLink ? $this->displayLink : Controller::curr()->Link();
551
	}
552
553
	/**
554
	 * Set the link to the page on which the form is displayed.
555
	 *
556
	 * The link defaults to the controllers current link. However if the form
557
	 * is displayed inside an action the display link must be explicitly set.
558
	 *
559
	 * @param string $link
560
	 */
561
	public function setDisplayLink($link) {
562
		$this->displayLink = $link;
563
	}
564
565
	/**
566
	 * Determine the steps to show in a linear fashion, starting from the
567
	 * first step. We run {@link getAllStepsRecursive} passing the steps found
568
	 * by reference to get a listing of the steps.
569
	 *
570
	 * @return DataObjectSet of MultiFormStep instances
571
	 */
572
	public function getAllStepsLinear() {
573
		$stepsFound = (class_exists('ArrayList')) ? new ArrayList() : new DataObjectSet();
574
575
		$firstStep = DataObject::get_one(static::$start_step, "\"SessionID\" = {$this->session->ID}");
576
		$firstStep->LinkingMode = ($firstStep->ID == $this->getCurrentStep()->ID) ? 'current' : 'link';
577
		$firstStep->addExtraClass('completed');
578
		$firstStep->setForm($this);
579
		$stepsFound->push($firstStep);
580
581
		// mark the further steps as non-completed if the first step is the current
582
		if ($firstStep->ID == $this->getCurrentStep()->ID) $this->currentStepHasBeenFound = true;
583
584
		$this->getAllStepsRecursive($firstStep, $stepsFound);
585
586
		return $stepsFound;
587
	}
588
589
	/**
590
	 * Recursively run through steps using the getNextStep() method on each step
591
	 * to determine what the next step is, gathering each step along the way.
592
	 * We stop on the last step, and return the results.
593
	 * If a step in the chain was already saved to the database in the current
594
	 * session, its used - otherwise a singleton of this step is used.
595
	 * Caution: Doesn't consider branching for steps which aren't in the database yet.
596
	 *
597
	 * @param $step Subclass of MultiFormStep to find the next step of
598
	 * @param $stepsFound $stepsFound DataObjectSet reference, the steps found to call back on
0 ignored issues
show
Documentation introduced by
The doc-type $stepsFound could not be parsed: Unknown type name "$stepsFound" at position 0. (view supported doc-types)

This check marks PHPDoc comments that could not be parsed by our parser. To see which comment annotations we can parse, please refer to our documentation on supported doc-types.

Loading history...
599
	 * @return DataObjectSet of MultiFormStep instances
600
	 */
601
	protected function getAllStepsRecursive($step, &$stepsFound) {
602
		// Find the next step to the current step, the final step has no next step
603
		if(!$step->isFinalStep()) {
604
			if($step->getNextStep()) {
605
				// Is this step in the DB? If it is, we use that
606
				$nextStep = $step->getNextStepFromDatabase();
607
				if(!$nextStep) {
608
					// If it's not in the DB, we use a singleton instance of it instead -
609
					// - this step hasn't been accessed yet
610
					$nextStep = singleton($step->getNextStep());
611
				}
612
613
				// once the current steps has been found we won't add the completed class anymore.
614
				if ($nextStep->ID == $this->getCurrentStep()->ID) $this->currentStepHasBeenFound = true;
615
616
				$nextStep->LinkingMode = ($nextStep->ID == $this->getCurrentStep()->ID) ? 'current' : 'link';
617
618
				// add the completed class
619
				if (!$this->currentStepHasBeenFound) $nextStep->addExtraClass('completed');
620
621
				$nextStep->setForm($this);
622
623
				// Add the array data, and do a callback
624
				$stepsFound->push($nextStep);
625
				$this->getAllStepsRecursive($nextStep, $stepsFound);
626
			}
627
		// Once we've reached the final step, we just return what we've collected
628
		} else {
629
			return $stepsFound;
630
		}
631
	}
632
633
	/**
634
	 * Number of steps already completed (excluding currently started step).
635
	 * The way we determine a step is complete is to check if it has the Data
636
	 * field filled out with a serialized value, then we know that the user has
637
	 * clicked next on the given step, to proceed.
638
	 *
639
	 * @TODO Not sure if it's entirely appropriate to check if Data is set as a
640
	 * way to determine a step is "completed".
641
	 *
642
	 * @return int
643
	 */
644
	public function getCompletedStepCount() {
645
		$steps = DataObject::get('MultiFormStep', "\"SessionID\" = {$this->session->ID} && \"Data\" IS NOT NULL");
646
		return $steps ? $steps->Count() : 0;
647
	}
648
649
	/**
650
	 * Total number of steps in the shortest path (only counting straight path without any branching)
651
	 * The way we determine this is to check if each step has a next_step string variable set. If it's
652
	 * anything else (like an array, for defining multiple branches) then it gets counted as a single step.
653
	 *
654
	 * @return int
655
	 */
656
	public function getTotalStepCount() {
657
		return $this->getAllStepsLinear() ? $this->getAllStepsLinear()->Count() : 0;
658
	}
659
660
	/**
661
	 * Percentage of steps completed (excluding currently started step)
662
	 *
663
	 * @return float
664
	 */
665
	public function getCompletedPercent() {
666
		return (float)$this->getCompletedStepCount() * 100 / $this->getTotalStepCount();
667
	}
668
669
}
0 ignored issues
show
Coding Style introduced by
According to PSR2, the closing brace of classes should be placed on the next line directly after the body.

Below you find some examples:

// Incorrect placement according to PSR2
class MyClass
{
    public function foo()
    {

    }
    // This blank line is not allowed.

}

// Correct
class MyClass
{
    public function foo()
    {

    } // No blank lines after this line.
}
Loading history...
670