pieform.php 68.9 KB
Newer Older
1
2
<?php
/**
3
4
 * Pieforms: Advanced web forms made easy
 * Copyright (C) 2006-2008 Catalyst IT Ltd (http://www.catalyst.net.nz)
5
 *
6
7
8
9
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
10
 *
11
12
13
14
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
15
 *
16
17
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
18
19
20
21
 *
 * @package    pieform
 * @subpackage core
 * @author     Nigel McNie <nigel@catalyst.net.nz>
22
23
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL version 3 or later
 * @copyright  For copyright information on Mahara, please see the README file distributed with this software.
24
25
26
 *
 */

27
28
$GLOBALS['_PIEFORM_REGISTRY'] = array();

Nigel McNie's avatar
Nigel McNie committed
29
30
31
32
33
34
35
/** The form was processed successfully */
define('PIEFORM_OK', 0);
/** The form failed processing/validating */
define('PIEFORM_ERR', -1);
/** A cancel button was pressed */
define('PIEFORM_CANCEL', -2);

36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
 * Builds, validates and processes a form.
 *
 * Given a form definition, and as long as one or two functions are implemented
 * by the caller, this function will handle everything else.
 *
 * USAGE:
 *
 * <pre>
 * $form = array(
 *     'name' => 'myform',
 *     'method' => 'post',
 *     'elements' => array(
 *         // definition of elements in the form
 *     )
 * );
 *
Nigel McNie's avatar
Nigel McNie committed
53
 * $smarty->assign('myform', pieform($form));
54
 *
Nigel McNie's avatar
Nigel McNie committed
55
 * function myform_validate(Pieform $form, $values) {
56
57
58
59
60
 *     // perform validation agains form elements here
 *     // some types of validation are conveniently available already as
 *     // as part of the form definition hash
 * }
 *
Nigel McNie's avatar
Nigel McNie committed
61
 * function myform_submit(Pieform $form, $values) {
62
63
64
65
 *     // perform action knowing that the values are valid, e.g. DB insert.
 * }
 * </pre>
 *
66
 * Please see http://pieforms.sourceforge.net/doc/html/ for
67
68
69
 * more information on creating and using forms.
 *
 */
70
function pieform($data) {/*{{{*/
71
    return Pieform::process($data);
72
73
}/*}}}*/

74
/**
75
 * Pieforms throws PieformExceptions when things go wrong
76
77
78
79
80
81
82
 */
class PieformException extends Exception {}

/**
 * Represents an HTML form. Forms created using this class have a lot of the
 * legwork for forms abstracted away.
 *
Nigel McNie's avatar
Nigel McNie committed
83
 * Pieforms makes it really easy to build complex HTML forms, simply by
84
85
86
 * building a hash describing your form, and defining one or two callback
 * functions.
 *
Nigel McNie's avatar
Nigel McNie committed
87
 * For more information on how Pieforms works, please see the documentation
88
 * at http://pieforms.sourceforge.net/doc/html/
89
 */
90
91
92
class Pieform {/*{{{*/

    /*{{{ Fields */
93
94
95
96
97
98
99
100
101

    /**
     * The form name. This is required.
     *
     * @var string
     */
    private $name = '';

    /**
Nigel McNie's avatar
Nigel McNie committed
102
     * Data for the form
103
104
105
     *
     * @var array
     */
Nigel McNie's avatar
Nigel McNie committed
106
    private $data = array();
107

108
    /**
Aaron Wells's avatar
Aaron Wells committed
109
     * A hash of references to the elements of the form, not including
110
     * fieldsets/containers (although including all elements inside any fieldsets/containers. Used
111
112
113
114
115
116
     * internally for simplifying looping over elements
     *
     * @var array
     */
    private $elementrefs = array();

117
118
119
    /**
     * Whether this form includes a file element. If so, the enctype attribute
     * for the form will be specified as "multipart/mixed" as required. This
Nigel McNie's avatar
Nigel McNie committed
120
     * is auto-detected by the Pieform class.
121
122
123
124
125
126
127
128
129
130
131
132
133
     *
     * @var bool
     */
    private $fileupload = false;

    /**
     * Whether the form has been submitted. Available through the
     * {@link is_submitted} method.
     *
     * @var bool
     */
    private $submitted = false;

134
    /**
Aaron Wells's avatar
Aaron Wells committed
135
     * Whether the form has been submitted by javasccript. Available through
136
137
138
139
140
141
142
143
     * the {@link submitted_by_js} method.
     *
     * @var bool
     */
    private $submitted_by_js = false;

    /*}}}*/

144
145
146
147
148
149
150
151
152
    /**
     * Whether the form has been submitted by dropzone.
     *
     * @var bool
     */
    private $submitted_by_dropzone = false;

    /*}}}*/

153
154
155
    /**
     * Processes the form. Called by the {@link pieform} function. It simply
     * builds the form (processing it if it has been submitted), and returns
156
     * the HTML to display the form.
157
158
159
160
     *
     * @param array $data The form description hash
     * @return string     The HTML representing the form
     */
161
    public static function process($data) {/*{{{*/
162
        $form = new Pieform($data);
163
164
165
166
167
168
        if ($form->get_property('backingout')) {
            return FALSE;
        }
        else {
            return $form->build();
        }
169
    }/*}}}*/
170
171
172
173
174
175
176
177

    /**
     * Sets the attributes of the form according to the passed data, performing
     * validation on the way. If the form is submitted, this checks and processes
     * the form.
     *
     * @param array $data The form description hash
     */
178
    public function __construct($data) {/*{{{*/
179
180
        $GLOBALS['_PIEFORM_REGISTRY'][] = $this;

181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
        if (!isset($data['name']) || !preg_match('/^[a-z_][a-z0-9_]*$/', $data['name'])) {
            throw new PieformException('Forms must have a name, and that name must be valid (validity test: could you give a PHP function the name?)');
        }
        $this->name = $data['name'];

        // If the form has global configuration, get it now
        if (function_exists('pieform_configure')) {
            $formconfig = pieform_configure();
            $defaultelements = (isset($formconfig['elements'])) ? $formconfig['elements'] : array();
            foreach ($defaultelements as $name => $element) {
                if (!isset($data['elements'][$name])) {
                    $data['elements'][$name] = $element;
                }
            }
        }
        else {
            $formconfig = array();
        }

        // Assign defaults for the form
201
202
        $this->defaults = self::get_pieform_defaults();
        $this->data = array_merge($this->defaults, $formconfig, $data);
203
204

        // Set the method - only get/post allowed
205
        $this->data['method'] = strtolower($this->data['method']);
Nigel McNie's avatar
Nigel McNie committed
206
207
        if ($this->data['method'] != 'post') {
            $this->data['method'] = 'get';
208
209
        }

Nigel McNie's avatar
Nigel McNie committed
210
211
212
        // Make sure that the javascript callbacks are valid
        if ($this->data['jsform']) {
            $this->validate_js_callbacks();
213
214
        }

Nigel McNie's avatar
Nigel McNie committed
215
216
        if (!$this->data['validatecallback']) {
            $this->data['validatecallback'] = $this->name . '_validate';
217
218
        }

Nigel McNie's avatar
Nigel McNie committed
219
220
        if (!$this->data['successcallback']) {
            $this->data['successcallback'] = $this->name . '_submit';
221
222
        }

223
224
225
226
        if (!$this->data['replycallback']) {
            $this->data['replycallback'] = $this->name . '_reply';
        }

Nigel McNie's avatar
Nigel McNie committed
227
228
229
        $this->data['configdirs'] = array_map(
            create_function('$a', 'return substr($a, -1) == "/" ? substr($a, 0, -1) : $a;'),
            (array) $this->data['configdirs']);
230
231


Nigel McNie's avatar
Nigel McNie committed
232
        if (empty($this->data['tabindex'])) {
233
            $this->data['tabindex'] = 0;
Nigel McNie's avatar
Nigel McNie committed
234
235
236
        }

        if (!is_array($this->data['elements']) || count($this->data['elements']) == 0) {
237
238
            throw new PieformException('Forms must have a list of elements');
        }
239

240
        if (isset($this->data['spam'])) {
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
            // Enable form tricks to make it harder for bots to fill in the form.
            // This was moved from lib/antispam.php, see:
            // http://wiki.mahara.org/Developer_Area/Specifications_in_Development/Anti-spam#section_7
            //
            // Use the spam_error() method in your _validate function to check whether a submitted form
            // has failed any of these checks.
            //
            // Available options:
            //  - hash:    An array of element names to be hashed.  Currently ids of input elements
            //             are also hashed, so you need to be careful if you include 'elementname' in
            //             the hash array, and make sure you rewrite any css or js so it doesn't rely on
            //             an id like 'formname_elementname'.
            //  - secret:  String used to hash the fields.
            //  - mintime: Minimum number of seconds that must pass between page load & form submission.
            //  - maxtime: Maximum number of seconds that must pass between page load & form submission.
            //  - reorder: Array of element names to be reordered at random.
257
258
259
260
            if (empty($this->data['spam']['secret']) || !isset($this->data['elements']['submit'])) {
                // @todo don't rely on submit element
                throw new PieformException('Forms with spam config must have a secret and submit element');
            }
261
            $this->time = isset($_POST['__timestamp']) ? $_POST['__timestamp'] : time();
262
            $spamelements1 = array(
263
                '__invisiblefield' => array(
264
265
266
267
268
269
270
                    'type'         => 'text',
                    'title'        => get_string('spamtrap'),
                    'defaultvalue' => '',
                    'class'        => 'dontshow',
                ),
            );
            $spamelements2 = array(
271
                '__timestamp' => array(
272
273
274
                    'type' => 'hidden',
                    'value' => $this->time,
                ),
275
                '__invisiblesubmit' => array(
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
                    'type'  => 'submit',
                    'value' => get_string('spamtrap'),
                    'class' => 'dontshow',
                ),
            );
            $insert = rand(0, count($this->data['elements']));
            $this->data['elements'] = array_merge(
                array_slice($this->data['elements'], 0, $insert, true),
                $spamelements1,
                array_slice($this->data['elements'], $insert, count($this->data['elements']) - $insert, true),
                $spamelements2
            );

            // Min & max number of seconds between page load & submission
            if (!isset($this->data['spam']['mintime'])) {
                $this->data['spam']['mintime'] = 0.01;
            }
            if (!isset($this->data['spam']['maxtime'])) {
                $this->data['spam']['maxtime'] = 86400;
            }

            if (empty($this->data['spam']['hash'])) {
                $this->data['spam']['hash'] = array();
            }
300
301
            $this->data['spam']['hash'][] = '__invisiblefield';
            $this->data['spam']['hash'][] = '__invisiblesubmit';
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
            $this->hash_fieldnames();

            if (isset($this->data['spam']['reorder'])) {
                // Reorder form fields randomly
                $order = $this->data['spam']['reorder'];
                shuffle($order);
                $order = array_combine($this->data['spam']['reorder'], $order);
                $temp = array();
                foreach (array_keys($this->data['elements']) as $k) {
                    if (isset($order[$k])) {
                        $temp[$order[$k]] = $this->data['elements'][$order[$k]];
                    }
                    else {
                        $temp[$k] = $this->data['elements'][$k];
                    }
                }
                $this->data['elements'] = $temp;
            }

            $this->spamerror = false;
        }

324
        // Get references to all the elements in the form, excluding fieldsets/containers
325
        foreach ($this->data['elements'] as $name => &$element) {
Aaron Wells's avatar
Aaron Wells committed
326
            // The name can be in the element itself. This is compatibility for
327
328
329
330
331
            // the perl version
            if (isset($element['name'])) {
                $name = $element['name'];
            }

332
333
334
            if (isset($element['type']) && ($element['type'] == 'fieldset' || $element['type'] == 'container')) {
                // Load the fieldset/container plugin as we know this form has one now
                $this->include_plugin('element', $element['type']);
335
                if ($this->get_property('template')) {
336
                    self::info("Your form '$this->name' has a " . $element['type'] . ", but is using a template. Fieldsets/containers make no sense when using templates");
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
                }

                foreach ($element['elements'] as $subname => &$subelement) {
                    if (isset($subelement['name'])) {
                        $subname = $subelement['name'];
                    }
                    $this->elementrefs[$subname] = &$subelement;
                    $subelement['name'] = $subname;
                }
                unset($subelement);
            }
            else {
                $this->elementrefs[$name] = &$element;
            }

352
353
            $element['name'] = isset($this->hashedfields[$name]) ? $this->hashedfields[$name] : $name;

354
355
356
        }
        unset($element);

Aaron Wells's avatar
Aaron Wells committed
357
        // Check that all elements have names compliant to PHP's variable naming policy
358
359
360
361
        // (otherwise things get messy later)
        foreach (array_keys($this->elementrefs) as $name) {
            if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name)) {
                throw new PieformException('Element "' . $name . '" is badly named (validity test: could you give a PHP variable the name?)');
362
363
364
            }
        }

365
        // Remove elements to ignore
Aaron Wells's avatar
Aaron Wells committed
366
        // This can't be done using $this->elementrefs, because you can't unset
367
        // an entry in there and have it unset the entry in $this->data['elements']
Nigel McNie's avatar
Nigel McNie committed
368
        foreach ($this->data['elements'] as $name => $element) {
369
            if (isset($element['type']) && ($element['type'] == 'fieldset' || $element['type'] == 'container')) {
370
371
                foreach ($element['elements'] as $subname => $subelement) {
                    if (!empty($subelement['ignore'])) {
Nigel McNie's avatar
Nigel McNie committed
372
                        unset ($this->data['elements'][$name]['elements'][$subname]);
373
                        unset($this->elementrefs[$subname]);
374
375
376
377
378
                    }
                }
            }
            else {
                if (!empty($element['ignore'])) {
Nigel McNie's avatar
Nigel McNie committed
379
                    unset($this->data['elements'][$name]);
380
                    unset($this->elementrefs[$name]);
381
382
383
384
                }
            }
        }

385
386
        // Set some attributes for all elements
        $autofocusadded = false;
387
        foreach ($this->elementrefs as $name => &$element) {
388
            if (count($element) == 0) {
Nigel McNie's avatar
Nigel McNie committed
389
                throw new PieformException('An element in form "' . $this->name . '" has no data (' . $name . ')');
390
            }
391
392

            if (!isset($element['type']) || $element['type'] == 'markup') {
393
394
395
396
397
398
                $element['type'] = 'markup';
                if (!isset($element['value'])) {
                    throw new PieformException('The markup element "'
                        . $name . '" has no value');
                }
            }
399
400
401
402
403
404
405
            else {
                // Now we know what type the element is, we can load the plugin for it
                $this->include_plugin('element',  $element['type']);

                // All elements should have at least the title key set
                if (!isset($element['title'])) {
                    $element['title'] = '';
406
                }
407

408
409
410
411
412
                // This function can be defined by the application using Pieforms,
                // and applies to all elements of this type
                $function = 'pieform_element_' . $element['type'] . '_configure';
                if (function_exists($function)) {
                    $element = $function($element);
413
414
                }

415
                // vvv --------------------------------------------------- vvv
Aaron Wells's avatar
Aaron Wells committed
416
                // After this point Pieforms can set or override attributes
417
                // without fear that the developer will be able to change them.
Nigel McNie's avatar
Nigel McNie committed
418

419
                // This function is defined by the plugin itself, to set
Aaron Wells's avatar
Aaron Wells committed
420
                // fields on the element that need to be set but should not
421
422
423
424
                // be set by the application
                $function = 'pieform_element_' . $element['type'] . '_set_attributes';
                if (function_exists($function)) {
                    $element = $function($element);
425
426
427
428
429
430
431

                    // Allow an element to remove itself from the form
                    if (!$element) {
                        unset($this->data['elements'][$name]);
                        unset($this->elementrefs[$name]);
                        continue;
                    }
432
                }
433

434
435
436
437
438
439
440
441
442
                // Force the form method to post if there is a file to upload
                if (!empty($element['needsmultipart'])) {
                    $this->fileupload = true;
                    if ($this->data['method'] == 'get') {
                        $this->data['method'] = 'post';
                        self::info("Your form '$this->name' had the method 'get' and also a file element - it has been converted to 'post'");
                    }
                }

443
444
445
446
447
448
449
450
                // Add the autofocus flag to the element if required
                if (!$autofocusadded && $this->data['autofocus'] === true && empty($element['nofocus'])) {
                    $element['autofocus'] = true;
                    $autofocusadded = true;
                }
                elseif (!empty($this->data['autofocus']) && $this->data['autofocus'] !== true
                    && $name == $this->data['autofocus']) {
                    $element['autofocus'] = true;
451
                }
Nigel McNie's avatar
Nigel McNie committed
452

453
454
455
456
457
                if (!empty($element['autofocus']) && $element['type'] == 'text' && !empty($this->data['autoselect'])
                    && $name == $this->data['autoselect']) {
                    $element['autoselect'] = true;
                }

458
459
460
                // All elements inherit the form tabindex
                $element['tabindex'] = $this->data['tabindex'];
            }
461
        }
462
        unset($element);
463
464

        // Check if the form was submitted, and if so, validate and process it
Nigel McNie's avatar
Nigel McNie committed
465
466
467
        $global = ($this->data['method'] == 'get') ? $_GET: $_POST;
        if ($this->data['validate'] && isset($global['pieform_' . $this->name] )) {
            if ($this->data['submit']) {
468
                $this->submitted = true;
469

Aaron Wells's avatar
Aaron Wells committed
470
                // If the hidden value the JS code inserts into the form is
471
472
473
474
475
                // present, then the form was submitted by JS
                if (!empty($global['pieform_jssubmission'])) {
                    $this->submitted_by_js = true;
                }

476
477
478
479
480
                // If the form was submitted via the dropzone
                if (!empty($global['dropzone'])) {
                    $this->submitted_by_dropzone = true;
                }

481
                // Check if the form has been cancelled
Nigel McNie's avatar
Nigel McNie committed
482
                if ($this->data['iscancellable']) {
483
484
                    foreach ($global as $key => $value) {
                        if (substr($key, 0, 7) == 'cancel_') {
Nigel McNie's avatar
Nigel McNie committed
485
                            // Check for and call the cancel function handler, if defined
486
                            $function = $this->name . '_' . $key;
Nigel McNie's avatar
Nigel McNie committed
487
488
                            if (function_exists($function)) {
                                $function($this);
489
                            }
Nigel McNie's avatar
Nigel McNie committed
490
491

                            // Redirect the user to where they should go, if the cancel handler didn't already
492
493
494
495
                            $element = $this->get_element(substr($key, 7));
                            if (!isset($element['goto'])) {
                                throw new PieformException('Cancel element "' . $element['name'] . '" has no page to go to');
                            }
496
497
                            if ($this->submitted_by_js) {
                                $this->json_reply(PIEFORM_CANCEL, array('location' => $element['goto']), false);
Nigel McNie's avatar
Nigel McNie committed
498
499
                            }
                            header('HTTP/1.1 303 See Other');
500
501
                            header('Location:' . $element['goto']);
                            exit;
502
503
504
505
506
507
508
509
510
511
512
                        }
                    }
                }
            }

            // Get the values that were submitted
            $values = $this->get_submitted_values();
            // Perform general validation first
            $this->validate($values);

            // Submit the form if things went OK
Nigel McNie's avatar
Nigel McNie committed
513
            if ($this->data['submit'] && !$this->has_errors()) {
514
                $submitted = false;
515
                foreach ($this->elementrefs as $name => $element) {
Nigel McNie's avatar
Nigel McNie committed
516
                    if (!empty($element['submitelement']) && isset($global[$element['name']])) {
517
518
519
520
                        if (!is_array($this->data['successcallback'])) {
                            $function = "{$this->data['successcallback']}_{$name}";
                            if (function_exists($function)) {
                                $function($this, $values);
521
522
                                log_debug('button-submit form ' . $function . ' should provide a redirect.');
                                return;
523
                            }
524
525
526
                        }
                    }
                }
Nigel McNie's avatar
Nigel McNie committed
527
528
                $function = $this->data['successcallback'];
                if (!$submitted && is_callable($function)) {
529
530
531
                    // Call the user defined function for processing a submit
                    // This function should really redirect/exit after it has
                    // finished processing the form.
Nigel McNie's avatar
Nigel McNie committed
532
533
534
                    call_user_func_array($function, array($this, $values));
                    if ($this->data['dieaftersubmit']) {
                        if ($this->data['jsform']) {
535
                            $message = 'Your ' . $this->name . '_submit function should use $form->reply to send a response, which should redirect or exit when it is done. Perhaps you want to make your reply callback do this?';
Nigel McNie's avatar
Nigel McNie committed
536
537
538
539
540
541
542
543
                        }
                        else {
                            $message = 'Your ' . $this->name . '_submit function should redirect or exit when it is done';
                        }
                        throw new PieformException($message);
                    }
                    else {
                        // Successful submission, and the user doesn't care about replying, so...
544
545
546
                        if (isset($this->data['backoutaftersubmit'])) {
                            $this->data['backingout'] = TRUE;
                        }
Nigel McNie's avatar
Nigel McNie committed
547
548
                        return;
                    }
549
                }
550
                else if (!$submitted) {
551
552
553
554
                    throw new PieformException('No function registered to handle form submission for form "' . $this->name . '"');
                }
            }

555
556
            // If we get here, the form was submitted but failed validation

557
            // Auto focus the first element with an error if required
Nigel McNie's avatar
Nigel McNie committed
558
            if ($this->data['autofocus'] !== false) {
559
560
                $this->auto_focus_first_error();
            }
Nigel McNie's avatar
Nigel McNie committed
561
562
563
564
565
566

            // Call the user-defined PHP error function, if it exists
            $function = $this->data['errorcallback'];
            if (is_callable($function)) {
                call_user_func_array($function, array($this));
            }
Aaron Wells's avatar
Aaron Wells committed
567

Nigel McNie's avatar
Nigel McNie committed
568
            // If the form has been submitted by javascript, return json
569
            if ($this->submitted_by_js) {
Aaron Wells's avatar
Aaron Wells committed
570
571
                // TODO: get error messages in a 'third person' type form to
                // use here maybe? Would have to work for non js forms too. See
572
573
574
575
576
577
                // the TODO file
                //$errors = $this->get_errors();
                //$json = array();
                //foreach ($errors as $element) {
                //    $json[$element['name']] = $element['error'];
                //}
Nigel McNie's avatar
Nigel McNie committed
578
                $message = $this->get_property('jserrormessage');
579
                $this->json_reply(PIEFORM_ERR, array('message' => $message));
580
            }
581
582
583
584
            else {
                global $SESSION;
                $SESSION->add_error_msg($this->get_property('errormessage'));
            }
585
        }
586
587
588
589
590
591
592
593
594
595
    }/*}}}*/

    /**
     * Returns the form name
     *
     * @return string
     */
    public function get_name() {/*{{{*/
        return $this->name;
    }/*}}}*/
596
597
598
599
600
601

    /**
     * Returns a generic property. This can be used to retrieve any property
     * set in the form data array, so developers can pass in random stuff and
     * get access to it.
     *
Aaron Wells's avatar
Aaron Wells committed
602
     * @param string The key of the property to return. If the property doesn't
603
     *               exist, null is returned
604
605
     * @return mixed
     */
606
607
608
609
610
611
    public function get_property($key) {/*{{{*/
        if (array_key_exists($key, $this->data)) {
            return $this->data[$key];
        }
        return null;
    }/*}}}*/
612

613
614
615
616
617
618
619
620
621
622
623
624
625
    /**
     * Sets a generic property. This can be used to alter a property
     * in the form data array.
     *
     * @param string $key    The key of the property to change.
     * @param string $value  The value to set the property to
     */
    public function set_property($key, $value) {
        if (array_key_exists($key, $this->data)) {
            $this->data[$key] = $value;
        }
    }

626
    /**
627
     * Returns whether the form has been submitted
628
     *
629
     * @return bool
630
     */
631
632
633
    public function is_submitted() {/*{{{*/
        return $this->submitted;
    }/*}}}*/
634
635

    /**
636
     * Returns whether the form has been submitted by javascript
637
638
639
     *
     * @return bool
     */
640
641
642
    public function submitted_by_js() {/*{{{*/
        return $this->submitted_by_js;
    }/*}}}*/
643

644
645
646
647
648
    /**
     * Returns the HTML for the <form...> tag
     *
     * @return string
     */
649
650
651
652
653
    public function get_form_tag() {/*{{{*/
        $result = '<form class="pieform';
        if ($this->has_errors()) {
            $result .= ' error';
        }
654
        if (isset($this->data['class'])) {
655
            $result .= ' ' . self::hsc($this->data['class']);
656
        }
657
        $result .= '"';
658
        foreach (array('name', 'method', 'action') as $attribute) {
659
            $result .= ' ' . $attribute . '="' . self::hsc($this->data[$attribute]) . '"';
660
661
662
663
664
665
        }
        $result .= ' id="' . $this->name . '"';
        if ($this->fileupload) {
            $result .= ' enctype="multipart/form-data"';
        }
        $result .= '>';
666
667
668
        if (!empty($this->error)) {
            $result .= '<div class="error">' . $this->error . '</div>';
        }
669
        return $result;
670
    }/*}}}*/
671

672
    /**
Aaron Wells's avatar
Aaron Wells committed
673
     * Builds and returns the HTML for the form, using the chosen renderer or
674
     * template
675
     *
676
     * Note that the "action" attribute for the form tag is NOT HTML escaped
677
678
679
680
     * for you. This allows you to build your own URLs, should you require. On
     * the other hand, this means you must be careful about escaping the URL,
     * especially if it has data from an external source in it.
     *
681
     * @param boolean Whether to include the <form...></form> tags in the output
682
683
     * @return string The form as HTML
     */
684
    public function build($outputformtags=true) {/*{{{*/
685
        $result = '';
686

Aaron Wells's avatar
Aaron Wells committed
687
        // Builds the HTML each element (see the build_element_html method for
688
689
        // more information)
        foreach ($this->data['elements'] as &$element) {
690
            if ($element['type'] == 'fieldset' || $element['type'] == 'container') {
691
692
693
694
695
696
697
698
                foreach ($element['elements'] as &$subelement) {
                    $this->build_element_html($subelement);
                }
                unset($subelement);
            }
            else {
                $this->build_element_html($element);
            }
699
        }
700
701
702
703
704
        unset($element);

        // If a template is to be used, use it instead of a renderer
        if (!empty($this->data['template'])) {
            $form_tag = $this->get_form_tag();
705

Aaron Wells's avatar
Aaron Wells committed
706
            // $elements is a convenience variable that contains all of the form elements (minus fieldsets and
707
708
709
710
711
712
            // hidden elements)
            $elements = array();
            foreach ($this->elementrefs as $element) {
                if ($element['type'] != 'hidden') {
                    $elements[$element['name']] = $element;
                }
713
714
            }

715
716
717
718
719
720
721
722
723
724
725
726
727
728
            // Hidden elements
            $this->include_plugin('element', 'hidden');
            $hidden_elements = '';
            foreach ($this->elementrefs as $element) {
                if ($element['type'] == 'hidden') {
                    $hidden_elements .= pieform_element_hidden($this, $element);
                }
            }
            $element = array(
                'type'  => 'hidden',
                'name'  => 'pieform_' . $this->get_name(),
                'value' => ''
            );
            $hidden_elements .= pieform_element_hidden($this, $element);
729

730
731
732
733
            ob_start();

            if ($this->get_property('ignoretemplatenotices')) {
                $old_level = error_reporting(E_ALL & ~E_NOTICE);
734
            }
735
736
737
738
739
740
741
742
743
744
745
746

            $templatepath = $this->get_property('templatedir');
            $templatepath = ($templatepath && substr($templatepath, -1) != '/') ? $templatepath . '/' : $templatepath;
            $templatepath .= $this->get_property('template');
            require($templatepath);

            if ($this->get_property('ignoretemplatenotices')) {
                error_reporting($old_level);
            }

            $result = ob_get_contents();
            ob_end_clean();
747
        }
748
749
750
751
752
        else {
            // No template being used - instead use a renderer
            if ($outputformtags) {
                $result = $this->get_form_tag() . "\n";
            }
753

754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
            $this->include_plugin('renderer',  $this->data['renderer']);

            // Form header
            $function = 'pieform_renderer_' . $this->data['renderer'] . '_header';
            if (function_exists($function)) {
                $result .= $function();
            }

            // Render each element
            foreach ($this->data['elements'] as $name => $element) {
                if ($element['type'] != 'hidden') {
                    $result .= pieform_render_element($this, $element);
                }
            }

            // Form footer
            $function = 'pieform_renderer_' . $this->data['renderer'] . '_footer';
            if (function_exists($function)) {
                $result .= $function();
            }

            // Hidden elements
            $this->include_plugin('element', 'hidden');
            foreach ($this->elementrefs as $element) {
                if ($element['type'] == 'hidden') {
                    $result .= pieform_element_hidden($this, $element);
                }
            }
            $element = array(
                'type'  => 'hidden',
                'name'  => 'pieform_' . $this->name,
                'value' => ''
            );
            $result .= pieform_element_hidden($this, $element);
Nigel McNie's avatar
Nigel McNie committed
788
            if ($outputformtags) {
789
                $result .= "</form>\n";
Nigel McNie's avatar
Nigel McNie committed
790
            }
Nigel McNie's avatar
Nigel McNie committed
791
        }
792

Aaron Wells's avatar
Aaron Wells committed
793
        // Output the javascript to wire things up, but only if it is needed.
794
        // The two cases where it is needed is when:
Aaron Wells's avatar
Aaron Wells committed
795
796
        // 1) The form is a JS form that hasn't been submitted yet. When the
        // form has been submitted the javascript from the first page load is
797
        // still active in the document
798
        // 2) The form is NOT a JS form, but has a presubmitcallback
799
800
801
        if ($outputformtags &&
            (($this->data['jsform'] && !$this->submitted)
             || (!$this->data['jsform'] && $this->data['presubmitcallback']))) {
Aaron Wells's avatar
Aaron Wells committed
802
803
            // Establish which buttons in the form are submit buttons. This is
            // used to detect which button was pressed to cause the form
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
            // submission
            $submitbuttons = array();
            foreach ($this->elementrefs as $element) {
                if (!empty($element['submitelement'])) {
                    // TODO: might have to deal with cancel elements here too
                    $submitbuttons[] = $element['name'];
                }
            }

            $data = json_encode(array(
                'name'                  => $this->name,
                'jsForm'                => $this->data['jsform'],
                'submitButtons'         => $submitbuttons,
                'preSubmitCallback'     => $this->data['presubmitcallback'],
                'jsSuccessCallback'     => $this->data['jssuccesscallback'],
                'jsErrorCallback'       => $this->data['jserrorcallback'],
                'globalJsErrorCallback' => $this->data['globaljserrorcallback'],
                'postSubmitCallback'    => $this->data['postsubmitcallback'],
822
                'newIframeOnSubmit'     => $this->data['newiframeonsubmit'],
823
                'checkDirtyChange'      => $this->data['checkdirtychange'],
824
            ));
825
            $result .= "<script type=\"application/javascript\">new Pieform($data);</script>\n";
826
        }
827
        return $result;
828
    }/*}}}*/
829
830
831
832
833
834
835
836

    /**
     * Given an element, gets the value for it from this form
     *
     * @param  array $element The element to get the value for
     * @return mixed          The element's value. <kbd>null</kbd> if no value
     *                        is available for the element.
     */
837
    public function get_value($element) {/*{{{*/
838
839
840
        if (isset($element['readonly']) && $element['readonly'] && isset($element['defaultvalue'])) {
            return $element['defaultvalue'];
        }
Nigel McNie's avatar
Nigel McNie committed
841
        $function = 'pieform_element_' . $element['type'] . '_get_value';
842
        if (function_exists($function)) {
Nigel McNie's avatar
Nigel McNie committed
843
            return $function($this, $element);
844
        }
Nigel McNie's avatar
Nigel McNie committed
845
        $global = ($this->data['method'] == 'get') ? $_GET : $_POST;
846
847
848
        // If the element is a submit element and has its value in the request, return it
        // Otherwise, we don't return the value if the form has been submitted, as they
        // aren't normally returned using a standard form.
Nigel McNie's avatar
Nigel McNie committed
849
        if (isset($element['value'])) {
850
851
            return $element['value'];
        }
852
        else if ($this->submitted && isset($global[$element['name']]) && $element['type'] != 'submit') {
853
854
855
856
857
858
            return $global[$element['name']];
        }
        else if (isset($element['defaultvalue'])) {
            return $element['defaultvalue'];
        }
        return null;
859
    }/*}}}*/
860
861
862
863

    /**
     * Retrieves a list of elements in the form.
     *
864
     * This flattens fieldsets/containers, and ignores the actual fieldset/container elements
865
866
     *
     * @return array The elements of the form
Aaron Wells's avatar
Aaron Wells committed
867
     */
868
    public function get_elements() {/*{{{*/
869
        $elements = array();
Nigel McNie's avatar
Nigel McNie committed
870
        foreach ($this->data['elements'] as $name => $element) {
871
            if ($element['type'] == 'fieldset' || $element['type'] == 'container') {
872
873
874
875
876
877
878
879
880
                foreach ($element['elements'] as $subelement) {
                    $elements[] = $subelement;
                }
            }
            else {
                $elements[] = $element;
            }
        }
        return $elements;
881
882
    }/*}}}*/

883
884
885
886
    /**
     * Returns the element with the given name. Throws a PieformException if the
     * element cannot be found.
     *
887
     * Fieldset and container elements are ignored. This might change if a valid case for
888
889
890
891
892
893
     * needing them is found.
     *
     * @param  string $name     The name of the element to find
     * @return array            The element
     * @throws PieformException If the element could not be found
     */
894
895
896
    public function get_element($name) {/*{{{*/
        if (isset($this->elementrefs[$name])) {
            return $this->elementrefs[$name];
897
898
        }

899
900
        throw new PieformException('Element "' . $name . '" cannot be found');
    }/*}}}*/
901

902
903
904
905
906
907
908
909
910
911
912
913
914
915
    /**
     * Sends a message back to a form
     */
    public function reply($returncode, $message) {
        if ($this->submitted_by_js()) {
            $this->json_reply($returncode, $message);
        }

        $function = $this->get_property('replycallback');
        if (function_exists($function)) {
            call_user_func_array($function, array($returncode, $message));
        }
    }

916
    /**
917
     * Sends a message back to a jsform.
918
     *
Aaron Wells's avatar
Aaron Wells committed
919
920
     * The message can contain almost any data, although how it is used is up to
     * the javascript callbacks.  The message must contain a return code (the
921
     * first parameter of this method.
922
     *
Aaron Wells's avatar
Aaron Wells committed
923
924
     * - The return code of the result. Either one of the PIEFORM_OK,
     *   PIEFORM_ERR or PIEFORM_CANCEL codes, or a custom error code at the
925
     *   choice of the application using pieforms
Aaron Wells's avatar
Aaron Wells committed
926
     * - A message. This is just a string that can be used as a status message,
927
     *   e.g. 'Form failed submission'
Aaron Wells's avatar
Aaron Wells committed
928
929
     * - HTML to replace the form with. By default, the form is built and used,
     *   but for example, you could replace the form with a "thank you" message
930
     *   after successful submission if you want
931
     */
932
933
934
935
936
    public function json_reply($returncode, $data=array(), $replacehtml=null) {/*{{{*/
        if (is_string($data)) {
            $data = array(
                'message' => $data,
            );
937
        }
938
939
940
        $data['returnCode'] = intval($returncode);
        if ($replacehtml === null) {
            $data['replaceHTML'] = $this->build();
941
        }
942
943
        else if (is_string($replacehtml)) {
            $data['replaceHTML'] = $replacehtml;
944
        }
945
946
947
        if (isset($this->hashedfields)) {
            $data['fieldnames'] = $this->hashedfields;
        }
948

Nigel McNie's avatar
Nigel McNie committed
949
        $result = json_encode($data);
950
951
952
953
        if ($this->submitted_by_dropzone) {
            echo $result;
            exit;
        }
Nigel McNie's avatar
Nigel McNie committed
954
        echo <<<EOF
955
<html><head><script type="application/javascript">function sendResult() { parent.pieformHandlers["{$this->name}"]($result); }</script></head><body onload="sendResult(); "></body></html>
Nigel McNie's avatar
Nigel McNie committed
956
957
EOF;
        exit;
958
    }/*}}}*/
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977

    /**
     * Returns whether a field has an error marked on it.
     *
     * This method should be used in the custom validation functions, to see if
     * there is an error on an element before checking for any more validation.
     *
     * Example:
     *
     * <code>
     * if (!$form->get_error('name') && /* condition {@*}) {
     *     $form->set_error('name', 'error message');
     * }
     * </code>
     *
     * @param  string $name  The name of the element to check
     * @return bool          Whether the element has an error
     * @throws PieformException If the element could not be found
     */
978
    public function get_error($name) {/*{{{*/
979
980
        $element = $this->get_element($name);
        return isset($element['error']);
981
    }/*}}}*/
982
983
984
985
986

    /**
     * Marks a field has having an error.
     *
     * This method should be used to set an error on an element in a custom
987
     * validation function, if one has occurred.
988
     *
989
990
991
     * @param string $name      The name of the element to set an error on
     * @param string $message   The error message
     * @param bool   $isescaped Whether to display error string as escaped or not
992
993
     * @throws PieformException  If the element could not be found
     */
994
    public function set_error($name, $message, $isescaped = true) {/*{{{*/
995
996
997
998
        if (is_null($name) && !empty($message)) {
            $this->error = $message;
            return;
        }
999
        foreach ($this->data['elements'] as $key => &$element) {
1000
            if ($element['type'] == 'fieldset' || $element['type'] == 'container') {
1001
1002
1003
                foreach ($element['elements'] as &$subelement) {
                    if ($subelement['name'] == $name) {
                        $subelement['error'] = $message;
1004
                        $subelement['isescaped'] = ($isescaped) ? true : false;
1005
                        $this->data['haserror'] = true;
1006
1007
1008
1009
1010
                        return;
                    }
                }
            }
            else {
1011
                if ($key == $name) {
1012
                    $element['error'] = $message;
1013
                    $element['isescaped'] = ($isescaped) ? true : false;
1014
                    $this->data['haserror'] = true;
1015
1016
1017
1018
1019
                    return;
                }
            }
        }
        throw new PieformException('Element "' . $name . '" could not be found');
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
    }/*}}}*/

    /**
     * Checks if there are errors on any of the form elements.
     *
     * @return bool Whether there are errors with the form
     */
    public function has_errors() {/*{{{*/
        foreach ($this->elementrefs as $element) {
            if (isset($element['error'])) {
                return true;
            }
        }
1033
        return isset($this->error);
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
    }/*}}}*/

    /**
     * Returns elements with errors on them
     *
     * @return array An array of elements with errors on them, the empty array
     *               in the result of no errors.
     */
    public function get_errors() {/*{{{*/
        $result = array();
        foreach ($this->elementrefs as $element) {
            if (isset($element['error'])) {
                $result[] = $element;
            }
        }
        return $result;
    }/*}}}*/
1051
1052
1053
1054
1055
1056
1057

    /**
     * Makes an ID for an element.
     *
     * Element IDs are used for <label>s, so use this method to ensure that
     * an element gets an ID.
     *
1058
1059
     * The element is assigned a random ID. Then overridden by 'name' and/or 'id'
     * if they are specified. If formname is required this is prepended to the string.
1060
1061
     *
     * @param array $element The element to make an ID for
1062
     * @param bool           Add the form name to the element ID string
1063
1064
     * @return string        The ID for the element
     */
1065
1066
1067
1068
1069
    public function make_id($element, $formname = false) {/*{{{*/
        $elementid = 'a' . substr(md5(mt_rand()), 0, 4);
        if (isset($element['name'])) {
            $elementid = self::hsc($element['name']);
        }
1070
        if (isset($element['id'])) {
1071
            $elementid = self::hsc($element['id']);
1072
        }
1073
1074
        if ($formname) {
            $elementid = $this->name . '_' . $elementid;
1075
        }
1076
        return $elementid;
1077
    }/*}}}*/
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090

    /**
     * Makes a class for an element.
     *
     * Elements can have several classes set on them depending on their state.
     * The classes are useful for (among other things), styling elements
     * differently if they are in these states.
     *
     * Currently, the states an element can be in are 'required' and 'error'.
     *
     * @param array $element The element to make a class for
     * @return string        The class for an element
     */
1091
    public function make_class($element) {/*{{{*/
1092
1093
        $classes = array();
        if (isset($element['class'])) {
1094
            $classes[] = self::hsc($element['class']);
1095
1096
1097
1098
1099
1100
1101
        }
        if (!empty($element['rules']['required'])) {
            $classes[] = 'required';
        }
        if (!empty($element['error'])) {
            $classes[] = 'error';
        }
1102
1103
1104
        if ($this->data['elementclasses']) {
            $classes[] = $element['type'];
        }
1105
1106
1107
        if (!empty($element['autoselect'])) {
            $classes[] = 'autoselect';
        }
1108
1109
        // Please make sure that 'autofocus' is the last class added in this
        // method. Otherwise, improve the logic for removing 'autofocus' from
1110
        // the element class string in pieform_render_element
1111
1112
1113
1114
        if (!empty($element['autofocus'])) {
            $classes[] = 'autofocus';
        }
        return implode(' ', $classes);
1115
    }/*}}}*/
1116
1117
1118