Completed
Push — master ( 243cae...47763d )
by Damian
02:03
created

MultiForm::getDisplayLink()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %
Metric Value
dl 0
loc 3
rs 10
cc 2
eloc 2
nc 2
nop 0
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 {
0 ignored issues
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...
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
	 * @var string
55
	 */
56
	private static $get_var = 'MultiFormSessionID';
0 ignored issues
show
Unused Code introduced by
The property $get_var 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...
57
	/**
58
	 * These fields are ignored when saving the raw form data into session.
59
	 * This ensures only field data is saved, and nothing else that's useless
60
	 * or potentially dangerous.
61
	 *
62
	 * @var array
63
	 */
64
	public static $ignored_fields = array(
65
		'url',
66
		'executeForm',
67
		'SecurityID'
68
	);
69
70
	/**
71
	 * Any of the actions defined in this variable are exempt from
72
	 * being validated.
73
	 *
74
	 * This is most useful for the "Back" (action_prev) action, as
75
	 * you typically don't validate the form when the user is going
76
	 * back a step.
77
	 *
78
	 * @var array
79
	 */
80
	public static $actions_exempt_from_validation = array(
81
		'action_prev'
82
	);
83
84
	/**
85
	 * @var string
86
	 */
87
	protected $displayLink;
88
89
	/**
90
	 * Flag which is being used in getAllStepsRecursive() to allow adding the completed flag on the steps
91
	 *
92
	 * @var boolean
93
	 */
94
	protected $currentStepHasBeenFound = false;
95
96
	/**
97
	 * Start the MultiForm instance.
98
	 *
99
	 * @param Controller instance $controller Controller this form is created on
100
	 * @param string $name The form name, typically the same as the method name
101
	 */
102
	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...
103
		// First set the controller and name manually so they are available for
104
		// field construction.
105
		$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...
106
		$this->name       = $name;
107
108
		// Set up the session for this MultiForm instance
109
		$this->setSession();
110
111
		// Get the current step available (Note: either returns an existing
112
		// step or creates a new one if none available)
113
		$currentStep = $this->getCurrentStep();
114
115
		// Set the step returned above as the current step
116
		$this->setCurrentStep($currentStep);
117
118
		// Set the form of the step to this form instance
119
		$currentStep->setForm($this);
120
121
		// Set up the fields for the current step
122
		$fields = $currentStep->getFields();
123
124
		// Set up the actions for the current step
125
		$actions = $this->actionsFor($currentStep);
126
127
		// Set up validation (if necessary)
128
		$validator = null;
129
		$applyValidation = true;
130
131
		$actionNames = static::$actions_exempt_from_validation;
132
133
		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...
134
			foreach ($actionNames as $exemptAction) {
135
				if(!empty($_REQUEST[$exemptAction])) {
136
					$applyValidation = false;
137
					break;
138
				}
139
			}
140
		}
141
142
		// Apply validation if the current step requires validation (is not exempt)
143
		if($applyValidation) {
144
			if($currentStep->getValidator()) {
145
				$validator = $currentStep->getValidator();
146
			}
147
		}
148
149
		// Give the fields, actions, and validation for the current step back to the parent Form class
150
		parent::__construct($controller, $name, $fields, $actions, $validator);
0 ignored issues
show
Bug introduced by
It seems like $fields defined by $currentStep->getFields() on line 122 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 145 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...
151
152
		$getVar = $this->config()->get_var;
153
154
		// Set a hidden field in our form with an encrypted hash to identify this session.
155
		$this->fields->push(new HiddenField($getVar, 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...
156
157
		// If there is saved data for the current step, we load it into the form it here
158
		//(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...
159
		if($data = $currentStep->loadData()) {
160
			$this->loadDataFrom($data);
161
		}
162
163
		// Disable security token - we tie a form to a session ID instead
164
		$this->disableSecurityToken();
165
166
		self::$ignored_fields[] = $getVar;
167
	}
168
169
	/**
170
	 * Accessor method to $this->controller.
171
	 *
172
	 * @return Controller this MultiForm was instanciated on.
173
	 */
174
	public function getController() {
175
		return $this->controller;
176
	}
177
178
	/**
179
	 * Returns the get_var to the template engine
180
	 *
181
	 * @return string
182
	 */
183
	public function getGetVar() {
184
		return $this->config()->get_var;
185
	}
186
187
	/**
188
	 * Get the current step.
189
	 *
190
	 * If StepID has been set in the URL, we attempt to get that record
191
	 * by the ID. Otherwise, we check if there's a current step ID in
192
	 * our session record. Failing those cases, we assume that the form has
193
	 * just been started, and so we create the first step and return it.
194
	 *
195
	 * @return MultiFormStep subclass
196
	 */
197
	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...
198
		$startStepClass = static::$start_step;
199
200
		// Check if there was a start step defined on the subclass of MultiForm
201
		if(!isset($startStepClass)) user_error(
202
			'MultiForm::init(): Please define a $start_step on ' . $this->class,
203
			E_USER_ERROR
204
		);
205
206
		// Determine whether we use the current step, or create one if it doesn't exist
207
		$currentStep = null;
208
		if(isset($_GET['StepID'])) {
209
			$stepID = (int)$_GET['StepID'];
210
			$currentStep = DataObject::get_one('MultiFormStep', "\"SessionID\" = {$this->session->ID} AND \"ID\" = {$stepID}");
211
		} 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...
212
			$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...
213
		}
214
215
		// Always fall back to creating a new step (in case the session or request data is invalid)
216
		if(!$currentStep || !$currentStep->ID) {
217
			$currentStep = Object::create($startStepClass);
218
			$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...
219
			$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...
220
			$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...
221
			$this->session->write();
222
			$this->session->flushCache();
223
		}
224
225
		if($currentStep) $currentStep->setForm($this);
226
227
		return $currentStep;
228
	}
229
230
	/**
231
	 * Set the step passed in as the current step.
232
	 *
233
	 * @param MultiFormStep $step A subclass of MultiFormStep
234
	 * @return boolean The return value of write()
235
	 */
236
	protected function setCurrentStep($step) {
237
		$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...
238
		$step->setForm($this);
239
240
		return $this->session->write();
241
	}
242
243
	/**
244
	 * Accessor method to $this->session.
245
	 *
246
	 * @return MultiFormSession
247
	 */
248
	public function getSession() {
249
		return $this->session;
250
	}
251
252
	/**
253
	 * Set up the session.
254
	 *
255
	 * If MultiFormSessionID isn't set, we assume that this is a new
256
	 * multiform that requires a new session record to be created.
257
	 *
258
	 * @TODO Fix the fact you can continually refresh and create new records
259
	 * if MultiFormSessionID isn't set.
260
	 *
261
	 * @TODO Not sure if we should bake the session stuff directly into MultiForm.
262
	 * Perhaps it would be best dealt with on a separate class?
263
	 */
264
	protected function setSession() {
265
		$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...
266
267
		// If there was no session found, create a new one instead
268
		if(!$this->session) {
269
			$this->session = new MultiFormSession();
270
		}
271
272
		// Create encrypted identification to the session instance if it doesn't exist
273
		if(!$this->session->Hash) {
274
			$this->session->Hash = sha1($this->session->ID . '-' . microtime());
275
			$this->session->write();
276
		}
277
	}
278
279
	/**
280
	 * Set the currently used encrypted hash to identify
281
	 * the MultiFormSession.
282
	 *
283
	 * @param string $hash Encrypted identification to session
284
	 */
285
	public function setCurrentSessionHash($hash) {
286
		$this->currentSessionHash = $hash;
287
		$this->setSession();
288
	}
289
290
	/**
291
	 * Return the currently used {@link MultiFormSession}
292
	 * @return MultiFormSession|boolean FALSE
293
	 */
294
	public function getCurrentSession() {
295
		if(!$this->currentSessionHash) {
296
			$this->currentSessionHash = $this->controller->request->getVar($this->config()->get_var);
297
298
			if(!$this->currentSessionHash) {
299
				return false;
300
			}
301
		}
302
303
		$this->session = MultiFormSession::get()->filter(array(
304
			"Hash" => $this->currentSessionHash,
305
			"IsComplete" => 0
306
		))->first();
307
308
		return $this->session;
309
	}
310
311
	/**
312
	 * Get all steps saved in the database for the currently active session,
313
	 * in the order they were saved, oldest to newest (automatically ordered by ID).
314
	 * If you want a full chain of steps regardless if they've already been saved
315
	 * to the database, use {@link getAllStepsLinear()}.
316
	 *
317
	 * @param string $filter SQL WHERE statement
318
	 * @return DataObjectSet|boolean A set of MultiFormStep subclasses
319
	 */
320
	public function getSavedSteps($filter = null) {
321
		$filter .= ($filter) ? ' AND ' : '';
322
		$filter .= sprintf("\"SessionID\" = '%s'", $this->session->ID);
323
		return DataObject::get('MultiFormStep', $filter);
324
	}
325
326
	/**
327
	 * Get a step which was previously saved to the database in the current session.
328
	 * Caution: This might cause unexpected behaviour if you have multiple steps
329
	 * in your chain with the same classname.
330
	 *
331
	 * @param string $className Classname of a {@link MultiFormStep} subclass
332
	 * @return MultiFormStep
333
	 */
334
	public function getSavedStepByClass($className) {
335
		return DataObject::get_one(
336
			'MultiFormStep',
337
			sprintf("\"SessionID\" = '%s' AND \"ClassName\" = '%s'",
338
				$this->session->ID,
339
				Convert::raw2sql($className)
340
			)
341
		);
342
	}
343
344
	/**
345
	 * Build a FieldList of the FormAction fields for the given step.
346
	 *
347
	 * If the current step is the final step, we push in a submit button, which
348
	 * calls the action {@link finish()} to finalise the submission. Otherwise,
349
	 * we push in a next button which calls the action {@link next()} to determine
350
	 * where to go next in our step process, and save any form data collected.
351
	 *
352
	 * If there's a previous step (a step that has the current step as it's next
353
	 * step class), then we allow a previous button, which calls the previous action
354
	 * to determine which step to go back to.
355
	 *
356
	 * If there are any extra actions defined in MultiFormStep->getExtraActions()
357
	 * then that set of actions is appended to the end of the actions FieldSet we
358
	 * have created in this method.
359
	 *
360
	 * @param $currentStep Subclass of MultiFormStep
361
	 * @return FieldList of FormAction objects
362
	 */
363
	public function actionsFor($step) {
364
		// Create default multi step actions (next, prev), and merge with extra actions, if any
365
		$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 391 which is incompatible with the return type documented by MultiForm::actionsFor of type FieldList.
Loading history...
366
367
		// If the form is at final step, create a submit button to perform final actions
368
		// The last step doesn't have a next button, so add that action to any step that isn't the final one
369
		if($step->isFinalStep()) {
370
			$actions->push(new FormAction('finish', $step->getSubmitText()));
371
		} else {
372
			$actions->push(new FormAction('next', $step->getNextText()));
373
		}
374
375
		// If there is a previous step defined, add the back button
376
		if($step->getPreviousStep() && $step->canGoBack()) {
377
			// If there is a next step, insert the action before the next action
378
			if($step->getNextStep()) {
379
				$actions->insertBefore($prev = 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...
380
			// Assume that this is the last step, insert the action before the finish action
381
			} else {
382
				$actions->insertBefore($prev = 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...
383
			}
384
			//remove browser validation from prev action
385
			$prev->setAttribute("formnovalidate", "formnovalidate");
386
		}
387
388
		// Merge any extra action fields defined on the step
389
		$actions->merge($step->getExtraActions());
390
391
		return $actions;
392
	}
393
394
	/**
395
	 * Return a rendered version of this form, with a specific template.
396
	 * Looks through the step ancestory templates (MultiFormStep, current step
397
	 * subclass template) to see if one is available to render the form with. If
398
	 * any of those don't exist, look for a default Form template to render
399
	 * with instead.
400
	 *
401
	 * @return SSViewer object to render the template with
402
	 */
403
	public function forTemplate() {
404
		$return = $this->renderWith(array(
405
			$this->getCurrentStep()->class,
406
			'MultiFormStep',
407
			$this->class,
408
			'MultiForm',
409
			'Form'
410
		));
411
412
		$this->clearMessage();
413
414
		return $return;
415
	}
416
417
	/**
418
	 * This method saves the data on the final step, after submitting.
419
	 * It should always be overloaded with parent::finish($data, $form)
420
	 * so you can create your own functionality which handles saving
421
	 * of all the data collected through each step of the form.
422
	 *
423
	 * @param array $data The request data returned from the form
424
	 * @param object $form The form that the action was called on
425
	 */
426
	public function finish($data, $form) {
427
		// Save the form data for the current step
428
		$this->save($data);
429
430
		if(!$this->getCurrentStep()->isFinalStep()) {
431
			$this->controller->redirectBack();
432
			return false;
433
		}
434
435 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...
436
			Session::set("FormInfo.{$form->FormName()}.data", $form->getData());
437
			$this->controller->redirectBack();
438
			return false;
439
		}
440
	}
441
442
	/**
443
	 * Determine what to do when the next action is called.
444
	 *
445
	 * Saves the current step session data to the database, creates the
446
	 * new step based on getNextStep() of the current step (or fetches
447
	 * an existing one), resets the current step to the next step,
448
	 * then redirects to the newly set step.
449
	 *
450
	 * @param array $data The request data returned from the form
451
	 * @param object $form The form that the action was called on
452
	 */
453
	public function next($data, $form) {
454
		// Save the form data for the current step
455
		$this->save($form->getData());
456
457
		// Get the next step class
458
		$nextStepClass = $this->getCurrentStep()->getNextStep();
459
460
		if(!$nextStepClass) {
461
			$this->controller->redirectBack();
462
			return false;
463
		}
464
465
		// Perform custom step validation (use MultiFormStep->getValidator() for
466
		// built-in functionality). The data needs to be manually saved on error
467
		// so the form is re-populated.
468 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...
469
			Session::set("FormInfo.{$form->FormName()}.data", $form->getData());
470
			$this->controller->redirectBack();
471
			return false;
472
		}
473
474
		// validation succeeded so we reset it to remove errors and messages
475
		$this->resetValidation();
476
477
		// Determine whether we can use a step already in the DB, or have to create a new one
478
		if(!$nextStep = DataObject::get_one($nextStepClass, "\"SessionID\" = {$this->session->ID}")) {
479
			$nextStep = Object::create($nextStepClass);
480
			$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...
481
			$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...
482
		}
483
484
		// Set the next step found as the current step
485
		$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...
486
487
		// Redirect to the next step
488
		$this->controller->redirect($nextStep->Link());
489
	}
490
491
	/**
492
	 * Determine what to do when the previous action is called.
493
	 *
494
	 * Retrieves the previous step class, finds the record for that
495
	 * class in the DB, and sets the current step to that step found.
496
	 * Finally, it redirects to that step.
497
	 *
498
	 * @param array $data The request data returned from the form
499
	 * @param object $form The form that the action was called on
500
	 */
501
	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...
502
		// Save the form data for the current step
503
		$this->save($form->getData());
504
505
		// Get the previous step class
506
		$prevStepClass = $this->getCurrentStep()->getPreviousStep();
507
508
		if(!$prevStepClass && !$this->getCurrentStep()->canGoBack()) {
509
			$this->controller->redirectBack();
510
			return false;
511
		}
512
513
		// Get the previous step of the class instance returned from $currentStep->getPreviousStep()
514
		$prevStep = DataObject::get_one($prevStepClass, "\"SessionID\" = {$this->session->ID}");
515
516
		// Set the current step as the previous step
517
		$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...
518
519
		// Redirect to the previous step
520
		$this->controller->redirect($prevStep->Link());
521
	}
522
523
	/**
524
	 * Save the raw data given back from the form into session.
525
	 *
526
	 * Take the submitted form data for the current step, removing
527
	 * any key => value pairs that shouldn't be saved, then saves
528
	 * the data into the session.
529
	 *
530
	 * @param array $data An array of data to save
531
	 */
532
	protected function save($data) {
533
		$currentStep = $this->getCurrentStep();
534
		if(is_array($data)) {
535
			foreach($data as $field => $value) {
536
				if(in_array($field, static::$ignored_fields)) {
537
					unset($data[$field]);
538
				}
539
			}
540
			$currentStep->saveData($data);
541
		}
542
		return;
543
	}
544
545
	// ############ Misc ############
546
547
	/**
548
	 * Add the MultiFormSessionID variable to the URL on form submission.
549
	 * This is a means to persist the session, by adding it's identification
550
	 * to the URL, which ties it back to this MultiForm instance.
551
	 *
552
	 * @return string
553
	 */
554
	public function FormAction() {
555
		$action = parent::FormAction();
556
		$action .= (strpos($action, '?')) ? '&amp;' : '?';
557
		$action .= "{$this->config()->get_var}={$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...
558
559
		return $action;
560
	}
561
562
	/**
563
	 * Returns the link to the page where the form is displayed. The user is
564
	 * redirected to this link with a session param after each step is
565
	 * submitted.
566
	 *
567
	 * @return string
568
	 */
569
	public function getDisplayLink() {
570
		return $this->displayLink ? $this->displayLink : Controller::curr()->Link();
571
	}
572
573
	/**
574
	 * Set the link to the page on which the form is displayed.
575
	 *
576
	 * The link defaults to the controllers current link. However if the form
577
	 * is displayed inside an action the display link must be explicitly set.
578
	 *
579
	 * @param string $link
580
	 */
581
	public function setDisplayLink($link) {
582
		$this->displayLink = $link;
583
	}
584
585
	/**
586
	 * Determine the steps to show in a linear fashion, starting from the
587
	 * first step. We run {@link getAllStepsRecursive} passing the steps found
588
	 * by reference to get a listing of the steps.
589
	 *
590
	 * @return DataObjectSet of MultiFormStep instances
591
	 */
592
	public function getAllStepsLinear() {
593
		$stepsFound = (class_exists('ArrayList')) ? new ArrayList() : new DataObjectSet();
594
595
		$firstStep = DataObject::get_one(static::$start_step, "\"SessionID\" = {$this->session->ID}");
596
		$firstStep->LinkingMode = ($firstStep->ID == $this->getCurrentStep()->ID) ? 'current' : 'link';
597
		$firstStep->addExtraClass('completed');
598
		$firstStep->setForm($this);
599
		$stepsFound->push($firstStep);
600
601
		// mark the further steps as non-completed if the first step is the current
602
		if ($firstStep->ID == $this->getCurrentStep()->ID) $this->currentStepHasBeenFound = true;
603
604
		$this->getAllStepsRecursive($firstStep, $stepsFound);
605
606
		return $stepsFound;
607
	}
608
609
	/**
610
	 * Recursively run through steps using the getNextStep() method on each step
611
	 * to determine what the next step is, gathering each step along the way.
612
	 * We stop on the last step, and return the results.
613
	 * If a step in the chain was already saved to the database in the current
614
	 * session, its used - otherwise a singleton of this step is used.
615
	 * Caution: Doesn't consider branching for steps which aren't in the database yet.
616
	 *
617
	 * @param $step Subclass of MultiFormStep to find the next step of
618
	 * @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...
619
	 * @return DataObjectSet of MultiFormStep instances
620
	 */
621
	protected function getAllStepsRecursive($step, &$stepsFound) {
622
		// Find the next step to the current step, the final step has no next step
623
		if(!$step->isFinalStep()) {
624
			if($step->getNextStep()) {
625
				// Is this step in the DB? If it is, we use that
626
				$nextStep = $step->getNextStepFromDatabase();
627
				if(!$nextStep) {
628
					// If it's not in the DB, we use a singleton instance of it instead -
629
					// - this step hasn't been accessed yet
630
					$nextStep = singleton($step->getNextStep());
631
				}
632
633
				// once the current steps has been found we won't add the completed class anymore.
634
				if ($nextStep->ID == $this->getCurrentStep()->ID) $this->currentStepHasBeenFound = true;
635
636
				$nextStep->LinkingMode = ($nextStep->ID == $this->getCurrentStep()->ID) ? 'current' : 'link';
637
638
				// add the completed class
639
				if (!$this->currentStepHasBeenFound) $nextStep->addExtraClass('completed');
640
641
				$nextStep->setForm($this);
642
643
				// Add the array data, and do a callback
644
				$stepsFound->push($nextStep);
645
				$this->getAllStepsRecursive($nextStep, $stepsFound);
646
			}
647
		// Once we've reached the final step, we just return what we've collected
648
		} else {
649
			return $stepsFound;
650
		}
651
	}
652
653
	/**
654
	 * Number of steps already completed (excluding currently started step).
655
	 * The way we determine a step is complete is to check if it has the Data
656
	 * field filled out with a serialized value, then we know that the user has
657
	 * clicked next on the given step, to proceed.
658
	 *
659
	 * @TODO Not sure if it's entirely appropriate to check if Data is set as a
660
	 * way to determine a step is "completed".
661
	 *
662
	 * @return int
663
	 */
664
	public function getCompletedStepCount() {
665
		$steps = DataObject::get('MultiFormStep', "\"SessionID\" = {$this->session->ID} && \"Data\" IS NOT NULL");
666
667
		return $steps ? $steps->Count() : 0;
668
	}
669
670
	/**
671
	 * Total number of steps in the shortest path (only counting straight path without any branching)
672
	 * The way we determine this is to check if each step has a next_step string variable set. If it's
673
	 * anything else (like an array, for defining multiple branches) then it gets counted as a single step.
674
	 *
675
	 * @return int
676
	 */
677
	public function getTotalStepCount() {
678
		return $this->getAllStepsLinear() ? $this->getAllStepsLinear()->Count() : 0;
679
	}
680
681
	/**
682
	 * Percentage of steps completed (excluding currently started step)
683
	 *
684
	 * @return float
685
	 */
686
	public function getCompletedPercent() {
687
		return (float) $this->getCompletedStepCount() * 100 / $this->getTotalStepCount();
688
	}
689
}
690