pieform.php 64 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
22
 *
 * @package    pieform
 * @subpackage core
 * @author     Nigel McNie <nigel@catalyst.net.nz>
 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL
23
 * @copyright  (C) 2006-2008 Catalyst IT Ltd http://catalyst.net.nz
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
102
103
104
105
106
107
108
109

    /**
     * Maintains a tab index across all created forms, to make it easy for
     * people to forget about it and have it just work for all of their forms.
     *
     * @var int
     */
    public static $formtabindex = 1;

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

    /**
Nigel McNie's avatar
Nigel McNie committed
110
     * Data for the form
111
112
113
     *
     * @var array
     */
Nigel McNie's avatar
Nigel McNie committed
114
    private $data = array();
115

116
117
118
119
120
121
122
123
124
    /**
     * A hash of references to the elements of the form, not including 
     * fieldsets (although including all elements inside any fieldsets. Used 
     * internally for simplifying looping over elements
     *
     * @var array
     */
    private $elementrefs = array();

125
126
127
    /**
     * 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
128
     * is auto-detected by the Pieform class.
129
130
131
132
133
134
135
136
137
138
139
140
141
     *
     * @var bool
     */
    private $fileupload = false;

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

142
143
144
145
146
147
148
149
150
151
    /**
     * Whether the form has been submitted by javasccript. Available through 
     * the {@link submitted_by_js} method.
     *
     * @var bool
     */
    private $submitted_by_js = false;

    /*}}}*/

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

    /**
     * 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
     */
172
    public function __construct($data) {/*{{{*/
173
174
        $GLOBALS['_PIEFORM_REGISTRY'][] = $this;

175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
        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
195
196
        $this->defaults = self::get_pieform_defaults();
        $this->data = array_merge($this->defaults, $formconfig, $data);
197
198

        // Set the method - only get/post allowed
199
        $this->data['method'] = strtolower($this->data['method']);
Nigel McNie's avatar
Nigel McNie committed
200
201
        if ($this->data['method'] != 'post') {
            $this->data['method'] = 'get';
202
203
        }

Nigel McNie's avatar
Nigel McNie committed
204
205
206
        // Make sure that the javascript callbacks are valid
        if ($this->data['jsform']) {
            $this->validate_js_callbacks();
207
208
        }

Nigel McNie's avatar
Nigel McNie committed
209
210
        if (!$this->data['validatecallback']) {
            $this->data['validatecallback'] = $this->name . '_validate';
211
212
        }

Nigel McNie's avatar
Nigel McNie committed
213
214
        if (!$this->data['successcallback']) {
            $this->data['successcallback'] = $this->name . '_submit';
215
216
        }

217
218
219
220
        if (!$this->data['replycallback']) {
            $this->data['replycallback'] = $this->name . '_reply';
        }

Nigel McNie's avatar
Nigel McNie committed
221
222
223
        $this->data['configdirs'] = array_map(
            create_function('$a', 'return substr($a, -1) == "/" ? substr($a, 0, -1) : $a;'),
            (array) $this->data['configdirs']);
224
225


Nigel McNie's avatar
Nigel McNie committed
226
227
228
229
230
        if (empty($this->data['tabindex'])) {
            $this->data['tabindex'] = self::$formtabindex++;
        }

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

234
        if (isset($this->data['spam'])) {
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
            // 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.
251
252
253
254
            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');
            }
255
            $this->time = isset($_POST['__timestamp']) ? $_POST['__timestamp'] : time();
256
            $spamelements1 = array(
257
                '__invisiblefield' => array(
258
259
260
261
262
263
264
                    'type'         => 'text',
                    'title'        => get_string('spamtrap'),
                    'defaultvalue' => '',
                    'class'        => 'dontshow',
                ),
            );
            $spamelements2 = array(
265
                '__timestamp' => array(
266
267
268
                    'type' => 'hidden',
                    'value' => $this->time,
                ),
269
                '__invisiblesubmit' => array(
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
                    '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();
            }
294
295
            $this->data['spam']['hash'][] = '__invisiblefield';
            $this->data['spam']['hash'][] = '__invisiblesubmit';
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
            $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;
        }

318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
        // Get references to all the elements in the form, excluding fieldsets
        foreach ($this->data['elements'] as $name => &$element) {
            // The name can be in the element itself. This is compatibility for 
            // the perl version
            if (isset($element['name'])) {
                $name = $element['name'];
            }

            if (isset($element['type']) && $element['type'] == 'fieldset') {
                // Load the fieldset plugin as we know this form has one now
                $this->include_plugin('element', 'fieldset');
                if ($this->get_property('template')) {
                    self::info("Your form '$this->name' has a fieldset, but is using a template. Fieldsets make no sense when using templates");
                }

                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;
            }

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

348
349
350
351
352
353
354
355
        }
        unset($element);

        // Check that all elements have names compliant to PHP's variable naming policy 
        // (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?)');
356
357
358
            }
        }

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

379
380
        // Set some attributes for all elements
        $autofocusadded = false;
381
        foreach ($this->elementrefs as $name => &$element) {
382
            if (count($element) == 0) {
Nigel McNie's avatar
Nigel McNie committed
383
                throw new PieformException('An element in form "' . $this->name . '" has no data (' . $name . ')');
384
            }
385
386

            if (!isset($element['type']) || $element['type'] == 'markup') {
387
388
389
390
391
392
                $element['type'] = 'markup';
                if (!isset($element['value'])) {
                    throw new PieformException('The markup element "'
                        . $name . '" has no value');
                }
            }
393
394
395
396
397
398
399
            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'] = '';
400
                }
401

402
403
404
405
406
                // 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);
407
408
                }

409
410
411
                // vvv --------------------------------------------------- vvv
                // After this point Pieforms can set or override attributes 
                // without fear that the developer will be able to change them.
Nigel McNie's avatar
Nigel McNie committed
412

413
414
415
416
417
418
419
                // This function is defined by the plugin itself, to set
                // fields on the element that need to be set but should not 
                // be set by the application
                $function = 'pieform_element_' . $element['type'] . '_set_attributes';
                if (function_exists($function)) {
                    $element = $function($element);
                }
420

421
422
423
424
425
426
427
428
429
                // 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'");
                    }
                }

430
431
432
433
434
435
436
437
                // 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;
438
                }
Nigel McNie's avatar
Nigel McNie committed
439

440
441
442
443
444
                if (!empty($element['autofocus']) && $element['type'] == 'text' && !empty($this->data['autoselect'])
                    && $name == $this->data['autoselect']) {
                    $element['autoselect'] = true;
                }

445
446
447
                // All elements inherit the form tabindex
                $element['tabindex'] = $this->data['tabindex'];
            }
448
        }
449
        unset($element);
450
451

        // Check if the form was submitted, and if so, validate and process it
Nigel McNie's avatar
Nigel McNie committed
452
453
454
        $global = ($this->data['method'] == 'get') ? $_GET: $_POST;
        if ($this->data['validate'] && isset($global['pieform_' . $this->name] )) {
            if ($this->data['submit']) {
455
                $this->submitted = true;
456
457
458
459
460
461
462

                // If the hidden value the JS code inserts into the form is 
                // present, then the form was submitted by JS
                if (!empty($global['pieform_jssubmission'])) {
                    $this->submitted_by_js = true;
                }

463
                // Check if the form has been cancelled
Nigel McNie's avatar
Nigel McNie committed
464
                if ($this->data['iscancellable']) {
465
466
                    foreach ($global as $key => $value) {
                        if (substr($key, 0, 7) == 'cancel_') {
Nigel McNie's avatar
Nigel McNie committed
467
                            // Check for and call the cancel function handler, if defined
468
                            $function = $this->name . '_' . $key;
Nigel McNie's avatar
Nigel McNie committed
469
470
                            if (function_exists($function)) {
                                $function($this);
471
                            }
Nigel McNie's avatar
Nigel McNie committed
472
473

                            // Redirect the user to where they should go, if the cancel handler didn't already
474
475
476
477
                            $element = $this->get_element(substr($key, 7));
                            if (!isset($element['goto'])) {
                                throw new PieformException('Cancel element "' . $element['name'] . '" has no page to go to');
                            }
478
479
                            if ($this->submitted_by_js) {
                                $this->json_reply(PIEFORM_CANCEL, array('location' => $element['goto']), false);
Nigel McNie's avatar
Nigel McNie committed
480
481
                            }
                            header('HTTP/1.1 303 See Other');
482
483
                            header('Location:' . $element['goto']);
                            exit;
484
485
486
487
488
489
490
491
492
493
494
                        }
                    }
                }
            }

            // 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
495
            if ($this->data['submit'] && !$this->has_errors()) {
496
                $submitted = false;
497
                foreach ($this->elementrefs as $name => $element) {
Nigel McNie's avatar
Nigel McNie committed
498
                    if (!empty($element['submitelement']) && isset($global[$element['name']])) {
499
                        $function = "{$this->data['successcallback']}_{$name}";
500
                        if (function_exists($function)) {
Nigel McNie's avatar
Nigel McNie committed
501
                            $function($this, $values);
502
503
504
505
506
                            $submitted = true;
                            break;
                        }
                    }
                }
Nigel McNie's avatar
Nigel McNie committed
507
508
                $function = $this->data['successcallback'];
                if (!$submitted && is_callable($function)) {
509
510
511
                    // 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
512
513
514
                    call_user_func_array($function, array($this, $values));
                    if ($this->data['dieaftersubmit']) {
                        if ($this->data['jsform']) {
515
                            $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
516
517
518
519
520
521
522
523
524
525
                        }
                        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...
                        return;
                    }
526
                }
527
                else if (!$submitted) {
528
529
530
531
                    throw new PieformException('No function registered to handle form submission for form "' . $this->name . '"');
                }
            }

532
533
            // If we get here, the form was submitted but failed validation

534
            // Auto focus the first element with an error if required
Nigel McNie's avatar
Nigel McNie committed
535
            if ($this->data['autofocus'] !== false) {
536
537
                $this->auto_focus_first_error();
            }
Nigel McNie's avatar
Nigel McNie committed
538
539
540
541
542
543

            // 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));
            }
544
            
Nigel McNie's avatar
Nigel McNie committed
545
            // If the form has been submitted by javascript, return json
546
547
548
549
550
551
552
553
554
            if ($this->submitted_by_js) {
                // TODO: get error messages in a 'third person' type form to 
                // use here maybe? Would have to work for non js forms too. See 
                // 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
555
                $message = $this->get_property('jserrormessage');
556
                $this->json_reply(PIEFORM_ERR, array('message' => $message));
557
558
            }
        }
559
560
561
562
563
564
565
566
567
568
    }/*}}}*/

    /**
     * Returns the form name
     *
     * @return string
     */
    public function get_name() {/*{{{*/
        return $this->name;
    }/*}}}*/
569
570
571
572
573
574

    /**
     * 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.
     *
575
576
     * @param string The key of the property to return. If the property doesn't 
     *               exist, null is returned
577
578
     * @return mixed
     */
579
580
581
582
583
584
    public function get_property($key) {/*{{{*/
        if (array_key_exists($key, $this->data)) {
            return $this->data[$key];
        }
        return null;
    }/*}}}*/
585
586

    /**
587
     * Returns whether the form has been submitted
588
     *
589
     * @return bool
590
     */
591
592
593
    public function is_submitted() {/*{{{*/
        return $this->submitted;
    }/*}}}*/
594
595

    /**
596
     * Returns whether the form has been submitted by javascript
597
598
599
     *
     * @return bool
     */
600
601
602
    public function submitted_by_js() {/*{{{*/
        return $this->submitted_by_js;
    }/*}}}*/
603

604
605
606
607
608
    /**
     * Returns the HTML for the <form...> tag
     *
     * @return string
     */
609
610
611
612
613
    public function get_form_tag() {/*{{{*/
        $result = '<form class="pieform';
        if ($this->has_errors()) {
            $result .= ' error';
        }
614
        if (isset($this->data['class'])) {
615
            $result .= ' ' . self::hsc($this->data['class']);
616
        }
617
        $result .= '"';
618
        foreach (array('name', 'method', 'action') as $attribute) {
619
            $result .= ' ' . $attribute . '="' . self::hsc($this->data[$attribute]) . '"';
620
621
622
623
624
625
        }
        $result .= ' id="' . $this->name . '"';
        if ($this->fileupload) {
            $result .= ' enctype="multipart/form-data"';
        }
        $result .= '>';
626
627
628
        if (!empty($this->error)) {
            $result .= '<div class="error">' . $this->error . '</div>';
        }
629
        return $result;
630
    }/*}}}*/
631

632
    /**
633
634
     * Builds and returns the HTML for the form, using the chosen renderer or 
     * template
635
     *
636
     * Note that the "action" attribute for the form tag is NOT HTML escaped
637
638
639
640
     * 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.
     *
641
     * @param boolean Whether to include the <form...></form> tags in the output
642
643
     * @return string The form as HTML
     */
644
    public function build($outputformtags=true) {/*{{{*/
645
        $result = '';
646

647
648
649
650
651
652
653
654
655
656
657
658
        // Builds the HTML each element (see the build_element_html method for 
        // more information)
        foreach ($this->data['elements'] as &$element) {
            if ($element['type'] == 'fieldset') {
                foreach ($element['elements'] as &$subelement) {
                    $this->build_element_html($subelement);
                }
                unset($subelement);
            }
            else {
                $this->build_element_html($element);
            }
659
        }
660
661
662
663
664
        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();
665

666
667
668
669
670
671
672
            // $elements is a convenience variable that contains all of the form elements (minus fieldsets and 
            // hidden elements)
            $elements = array();
            foreach ($this->elementrefs as $element) {
                if ($element['type'] != 'hidden') {
                    $elements[$element['name']] = $element;
                }
673
674
            }

675
676
677
678
679
680
681
682
683
684
685
686
687
688
            // 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);
689

690
691
692
693
            ob_start();

            if ($this->get_property('ignoretemplatenotices')) {
                $old_level = error_reporting(E_ALL & ~E_NOTICE);
694
            }
695
696
697
698
699
700
701
702
703
704
705
706

            $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();
707
        }
708
709
710
711
712
        else {
            // No template being used - instead use a renderer
            if ($outputformtags) {
                $result = $this->get_form_tag() . "\n";
            }
713

714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
            $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
748
            if ($outputformtags) {
749
                $result .= "</form>\n";
Nigel McNie's avatar
Nigel McNie committed
750
            }
Nigel McNie's avatar
Nigel McNie committed
751
        }
752
753
754
755
756

        // Output the javascript to wire things up, but only if it is needed. 
        // The two cases where it is needed is when:
        // 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 
757
        // still active in the document
758
        // 2) The form is NOT a JS form, but has a presubmitcallback
759
760
761
        if ($outputformtags &&
            (($this->data['jsform'] && !$this->submitted)
             || (!$this->data['jsform'] && $this->data['presubmitcallback']))) {
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
            // Establish which buttons in the form are submit buttons. This is 
            // used to detect which button was pressed to cause the form 
            // 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'],
782
                'newIframeOnSubmit'     => $this->data['newiframeonsubmit'],
783
784
785
            ));
            $result .= "<script type=\"text/javascript\">new Pieform($data);</script>\n";
        }
786
787

        return $result;
788
    }/*}}}*/
789
790
791
792
793
794
795
796

    /**
     * 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.
     */
797
    public function get_value($element) {/*{{{*/
Nigel McNie's avatar
Nigel McNie committed
798
        $function = 'pieform_element_' . $element['type'] . '_get_value';
799
        if (function_exists($function)) {
Nigel McNie's avatar
Nigel McNie committed
800
            return $function($this, $element);
801
        }
Nigel McNie's avatar
Nigel McNie committed
802
        $global = ($this->data['method'] == 'get') ? $_GET : $_POST;
803
804
805
        // 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
806
        if (isset($element['value'])) {
807
808
            return $element['value'];
        }
809
        else if ($this->submitted && isset($global[$element['name']]) && $element['type'] != 'submit') {
810
811
812
813
814
815
            return $global[$element['name']];
        }
        else if (isset($element['defaultvalue'])) {
            return $element['defaultvalue'];
        }
        return null;
816
    }/*}}}*/
817
818
819
820
821
822
823
824

    /**
     * Retrieves a list of elements in the form.
     *
     * This flattens fieldsets, and ignores the actual fieldset elements
     *
     * @return array The elements of the form
     */ 
825
    public function get_elements() {/*{{{*/
826
        $elements = array();
Nigel McNie's avatar
Nigel McNie committed
827
        foreach ($this->data['elements'] as $name => $element) {
828
829
830
831
832
833
834
835
836
837
            if ($element['type'] == 'fieldset') {
                foreach ($element['elements'] as $subelement) {
                    $elements[] = $subelement;
                }
            }
            else {
                $elements[] = $element;
            }
        }
        return $elements;
838
839
    }/*}}}*/

840
841
842
843
844
845
846
847
848
849
850
    /**
     * Returns the element with the given name. Throws a PieformException if the
     * element cannot be found.
     *
     * Fieldset elements are ignored. This might change if a valid case for
     * 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
     */
851
852
853
    public function get_element($name) {/*{{{*/
        if (isset($this->elementrefs[$name])) {
            return $this->elementrefs[$name];
854
855
        }

856
857
        throw new PieformException('Element "' . $name . '" cannot be found');
    }/*}}}*/
858

859
860
861
862
863
864
865
866
867
868
869
870
871
872
    /**
     * 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));
        }
    }

873
    /**
874
     * Sends a message back to a jsform.
875
     *
876
877
878
     * 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 
     * first parameter of this method.
879
     *
880
881
882
883
884
885
886
887
     * - 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 
     *   choice of the application using pieforms
     * - A message. This is just a string that can be used as a status message, 
     *   e.g. 'Form failed submission'
     * - 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 
     *   after successful submission if you want
888
     */
889
890
891
892
893
    public function json_reply($returncode, $data=array(), $replacehtml=null) {/*{{{*/
        if (is_string($data)) {
            $data = array(
                'message' => $data,
            );
894
        }
895
896
897
        $data['returnCode'] = intval($returncode);
        if ($replacehtml === null) {
            $data['replaceHTML'] = $this->build();
898
        }
899
900
        else if (is_string($replacehtml)) {
            $data['replaceHTML'] = $replacehtml;
901
        }
902
903
904
        if (isset($this->hashedfields)) {
            $data['fieldnames'] = $this->hashedfields;
        }
905

Nigel McNie's avatar
Nigel McNie committed
906
907
908
        $result = json_encode($data);

        echo <<<EOF
909
<html><head><script type="text/javascript">function sendResult() { parent.pieformHandlers["{$this->name}"]($result); }</script></head><body onload="sendResult(); "></body></html>
Nigel McNie's avatar
Nigel McNie committed
910
911
EOF;
        exit;
912
    }/*}}}*/
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931

    /**
     * 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
     */
932
    public function get_error($name) {/*{{{*/
933
934
        $element = $this->get_element($name);
        return isset($element['error']);
935
    }/*}}}*/
936
937
938
939
940
941
942
943
944
945
946

    /**
     * Marks a field has having an error.
     *
     * This method should be used to set an error on an element in a custom
     * validation function, if one has occured.
     *
     * @param string $name    The name of the element to set an error on
     * @param string $message The error message
     * @throws PieformException  If the element could not be found
     */
947
    public function set_error($name, $message) {/*{{{*/
948
949
950
951
        if (is_null($name) && !empty($message)) {
            $this->error = $message;
            return;
        }
952
        foreach ($this->data['elements'] as $key => &$element) {
953
954
955
956
957
958
959
960
961
            if ($element['type'] == 'fieldset') {
                foreach ($element['elements'] as &$subelement) {
                    if ($subelement['name'] == $name) {
                        $subelement['error'] = $message;
                        return;
                    }
                }
            }
            else {
962
                if ($key == $name) {
963
964
965
966
967
968
                    $element['error'] = $message;
                    return;
                }
            }
        }
        throw new PieformException('Element "' . $name . '" could not be found');
969
970
971
972
973
974
975
976
977
978
979
980
981
    }/*}}}*/

    /**
     * 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;
            }
        }
982
        return isset($this->error);
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
    }/*}}}*/

    /**
     * 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;
    }/*}}}*/
1000
1001
1002
1003
1004
1005
1006
1007

    /**
     * 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.
     *
     * The element's existing 'id' and 'name' attributes are checked first. If
1008
     * they are not specified, a random ID is created
1009
1010
1011
1012
     *
     * @param array $element The element to make an ID for
     * @return string        The ID for the element
     */
1013
    public function make_id($element) {/*{{{*/
1014
1015
1016
1017
1018
1019
        if (isset($element['id'])) {
            return self::hsc($element['id']);
        }
        if (isset($element['name'])) {
            return self::hsc($element['name']);
        }
1020
        return 'a' . substr(md5(mt_rand()), 0, 4);
1021
    }/*}}}*/
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034

    /**
     * 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
     */
1035
    public function make_class($element) {/*{{{*/
1036
1037
        $classes = array();
        if (isset($element['class'])) {
1038
            $classes[] = self::hsc($element['class']);
1039
1040
1041
1042
1043
1044
1045
        }
        if (!empty($element['rules']['required'])) {
            $classes[] = 'required';
        }
        if (!empty($element['error'])) {
            $classes[] = 'error';
        }
1046
1047
1048
        if ($this->data['elementclasses']) {
            $classes[] = $element['type'];
        }
1049
1050
1051
        if (!empty($element['autoselect'])) {
            $classes[] = 'autoselect';
        }
1052
1053
        // Please make sure that 'autofocus' is the last class added in this
        // method. Otherwise, improve the logic for removing 'autofocus' from
1054
        // the element class string in pieform_render_element
1055
1056
1057
1058
        if (!empty($element['autofocus'])) {
            $classes[] = 'autofocus';
        }
        return implode(' ', $classes);
1059
    }/*}}}*/
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083

    /**
     * Given an element, returns a string representing the basic attribute
     * list for the element.
     *
     * This EXCLUDES the "value" attribute, as various form elements set
     * their value in different ways.
     *
     * This allows each element to have most of the standard HTML attributes
     * that you can normally set on a form element.
     *
     * The attributes generated by this method will include (if set for the
     * element itself), are <kbd>accesskey, class, dir, id, lang, maxlength,
     * name, size, style</kbd> and <kbd>tabindex</kbd>.
     *
     * The <kbd>class</kbd> and <kbd>id</kbd> attributes are typically built
     * beforehand with {@link make_class} and {@link make_id} respectively.
     * The <kbd>maxlength</kbd> attribute is only set if the element has a
     * "maxlength" rule on it.
     *
     * @param array $element The element to make attributes for
     * @param array $exclude Any attributes to explicitly exclude from adding
     * @return string        The attributes for the element
     */
1084
    public function element_attributes($element, $exclude=array()) {/*{{{*/
1085
        static $attributes = array('accesskey', 'autocomplete', 'class', 'dir', 'id', 'lang', 'name', 'onclick', 'size', 'style', 'tabindex');
1086
1087
1088
1089
        $elementattributes = array_diff($attributes, $exclude);
        $result = '';
        foreach ($elementattributes as $attribute) {
            if (isset($element[$attribute]) && $element[$attribute] !== '') {
1090
1091
1092
                if ($attribute == 'id') {
                    $element[$attribute] = $this->name . '_' . $element[$attribute];
                }
1093
1094
1095
1096
                $result .= ' ' . $attribute . '="' . self::hsc($element[$attribute]) . '"';
            }
        }

1097
1098
1099
1100
        if (isset($element['elementtitle'])) {
            $result .= ' title="' . self::hsc($element['elementtitle']) . '"';
        }

1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
        if (!in_array('maxlength', $exclude) && isset($element['rules']['maxlength'])) {
            $result .= ' maxlength="' . intval($element['rules']['maxlength']) . '"';
        }

        foreach (array_diff(array('disabled', 'readonly'), $exclude) as $attribute) {
            if (!empty($element[$attribute])) {
                $result .= " $attribute=\"$attribute\"";
            }
        }
        
        return $result;
1112
    }/*}}}*/
1113

1114
1115
1116
1117
1118
1119
1120
    /**
     * Includes a plugin file, checking any configured plugin directories.
     *
     * @param string $type The type of plugin to include: 'element', 'renderer' or 'rule'
     * @param string $name The name of the plugin to include
     * @throws PieformException If the given type or plugin could not be found
     */
1121
    public function include_plugin($type, $name) {/*{{{*/
1122
1123
1124
1125
        if (!in_array($type, array('element', 'renderer', 'rule'))) {
            throw new PieformException("The type \"$type\" is not allowed for an include plugin");
        }

1126
1127
1128
1129
        if (!isset($name) || !preg_match('/^[a-z_][a-z0-9_]*$/', $name)) {
            throw new PieformException("The name \"$name\" is not valid (validity test: could you give a PHP function the name?)");
        }

1130
        // Check the configured include paths if they are specified
Nigel McNie's avatar
Nigel McNie committed
1131
        foreach ($this->data['configdirs'] as $directory) {
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
            $file = "$directory/{$type}s/$name.php";
            if (is_readable($file)) {
                include_once($file);
                return;
            }
        }

        // Check the default include path
        $file = dirname(__FILE__) . "/pieform/{$type}s/{$name}.php";
        if (is_readable($file)) {
            include_once($file);
            return;
        }

        throw new PieformException("Could not find $type \"$name\"");
1147
    }/*}}}*/
1148

1149
1150
1151
    /**
     * Return an internationalised string based on the passed input key
     *
1152
     * Returns English by default.
1153
     *
Nigel McNie's avatar
Nigel McNie committed
1154
1155
1156
1157
1158
1159
1160
     * @param string $plugin     The type of plugin (element, renderer, rule)
     * @param string $pluginname The name of the plugin to get the language
     *                           strings for
     * @param string $key        The language key to look up
     * @param array  $element    The element to get the string for. Elements
     *                           can specify there own i18n strings for rules
     * @return string            The internationalised string
1161
     */
1162
    public function i18n($plugin, $pluginname, $key, $element=null) {/*{{{*/
Nigel McNie's avatar
Nigel McNie committed
1163
1164
        if (!in_array($plugin, array('element', 'renderer', 'rule'))) {
            throw new PieformException("Invalid plugin name '$plugin'");
1165
        }
Nigel McNie's avatar
Nigel McNie committed
1166

1167
1168
1169
1170
1171
1172
1173
1174
        if (!isset($pluginname) || !preg_match('/^[a-z_][a-z0-9_]*$/', $pluginname)) {
            throw new PieformException("The pluginname \"$pluginname\" is not valid (validity test: could you give a PHP function the name?)");
        }

        if (!isset($key) || !preg_match('/^[a-z_][a-z0-9_]*$/', $key)) {
            throw new PieformException("The key \"$key\" is not valid (validity test: could you give a PHP function the name?)");
        }

Nigel McNie's avatar
Nigel McNie committed
1175
1176
1177
        // Check the element itself for the language string
        if ($plugin == 'rule' && isset($element['rulei18n'][$key])) {
            return $element['rulei18n'][$key];
1178
        }
Nigel McNie's avatar
Nigel McNie committed
1179
1180
1181
1182
1183
1184
1185

        // Check to see if a default was configured for the form
        if ($plugin == 'rule' && isset($this->data['rulei18n'][$key])) {
            return $this->data['rulei18n'][$key];
        }

        // Fall back to the default string
1186
        $this->include_plugin($plugin, $pluginname);
Nigel McNie's avatar
Nigel McNie committed
1187
1188
1189
        $function = 'pieform_' . $plugin . '_' . $pluginname . '_i18n';
        if (function_exists($function)) {
            $strings = $function();
1190
1191
1192
            if (isset($strings[$this->data['language']][$key])) {
                return $strings[$this->data['language']][$key];
            }
1193
1194
1195
1196
            // If all else fails, try to get the string in the default language.
            if (isset($strings[$this->defaults['language']][$key])) {
                return $strings[$this->defaults['language']][$key];
            }
1197
            return '[[' . $key . '/' . $this->data['language'] . ']]';
Nigel McNie's avatar
Nigel McNie committed
1198
1199
1200
        }

        // We don't recognise this string
1201
        return '[[' . $key . ']]';
1202
    }/*}}}*/
1203
1204
1205
1206
1207
1208
1209

    /**
     * HTML-escapes the given value
     *
     * @param string $text The text to escape
     * @return string      The text, HTML escaped
     */
1210
    public static function hsc($text) {/*{{{*/
1211
        return htmlspecialchars($text, ENT_COMPAT, 'UTF-8');
1212
    }/*}}}*/
1213
1214
1215

    /**
     * Hook for giving information back to the developer
Nigel McNie's avatar
Nigel McNie committed
1216
1217
     *
     * @param string $message The message to give to the developer
1218
     */
1219
    public static function info($message) {/*{{{*/
1220
1221
1222
1223
1224
1225
1226
        $function = 'pieform_info';
        if (function_exists($function)) {
            $function($message);
        }
        else {
            trigger_error($message, E_USER_NOTICE);
        }
1227
    }/*}}}*/
1228

Nigel McNie's avatar
Nigel McNie committed
1229
1230
1231
1232
    /**
     * Makes sure that the javascript callbacks for this form are valid javascript
     * function names.
     */
1233
    private function validate_js_callbacks() {/*{{{*/
Nigel McNie's avatar
Nigel McNie committed
1234
1235
1236
1237
1238
        foreach (array('presubmitcallback', 'postsubmitcallback', 'jssuccesscallback',
            'jserrorcallback', 'globaljserrorcallback') as $callback) {
            if ($this->data[$callback] != '' && !preg_match('/^[a-zA-Z][a-zA-Z0-9_]*$/', $this->data[$callback])) {
                throw new PieformException("'{$this->data[$callback]}' is not a valid javascript callback name for callback '$callback'");
            }