pieform.php 69.6 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
    /**
     * Whether the form has been submitted by dropzone.
     *
     * @var bool
     */
    private $submitted_by_dropzone = false;

151 152
    private $submitvalue = 'submit';

153 154
    /*}}}*/

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

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

183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202
        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
203 204
        $this->defaults = self::get_pieform_defaults();
        $this->data = array_merge($this->defaults, $formconfig, $data);
205 206

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

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

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

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

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

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


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

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

242
        if (isset($this->data['spam'])) {
243 244
            // Enable form tricks to make it harder for bots to fill in the form.
            // This was moved from lib/antispam.php, see:
245
            // https://wiki.mahara.org/wiki/Developer_Area/Specifications_in_Development/Anti-spam#section_7
246 247 248 249 250 251 252 253 254 255 256 257 258
            //
            // 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.
259 260 261 262
            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');
            }
263
            $this->time = isset($_POST['__timestamp']) ? $_POST['__timestamp'] : time();
264
            $spamelements1 = array(
265
                '__invisiblefield' => array(
266 267 268
                    'type'         => 'text',
                    'title'        => get_string('spamtrap'),
                    'defaultvalue' => '',
269
                    'class'        => 'dontshow hidden',
270 271 272
                ),
            );
            $spamelements2 = array(
273
                '__timestamp' => array(
274 275 276
                    'type' => 'hidden',
                    'value' => $this->time,
                ),
277
                '__invisiblesubmit' => array(
278 279
                    'type'  => 'submit',
                    'value' => get_string('spamtrap'),
280
                    'class' => 'dontshow hidden',
281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301
                ),
            );
            $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();
            }
302 303
            $this->data['spam']['hash'][] = '__invisiblefield';
            $this->data['spam']['hash'][] = '__invisiblesubmit';
304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325
            $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;
        }

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

334 335 336
            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']);
337
                if ($this->get_property('template')) {
338
                    self::info("Your form '$this->name' has a " . $element['type'] . ", but is using a template. Fieldsets/containers make no sense when using templates");
339 340 341 342 343 344 345 346 347 348 349 350 351 352 353
                }

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

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

356 357 358
        }
        unset($element);

Aaron Wells's avatar
Aaron Wells committed
359
        // Check that all elements have names compliant to PHP's variable naming policy
360 361 362 363
        // (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?)');
364 365 366
            }
        }

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

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

            if (!isset($element['type']) || $element['type'] == 'markup') {
395 396 397 398 399 400
                $element['type'] = 'markup';
                if (!isset($element['value'])) {
                    throw new PieformException('The markup element "'
                        . $name . '" has no value');
                }
            }
401 402 403 404 405 406 407
            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'] = '';
408
                }
409

410 411 412 413 414
                // 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);
415 416
                }

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

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

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

436 437 438 439 440 441 442 443 444
                // 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'");
                    }
                }

445 446 447 448 449 450 451 452
                // 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;
453
                }
Nigel McNie's avatar
Nigel McNie committed
454

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

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

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


Nigel McNie's avatar
Nigel McNie committed
470 471
        if ($this->data['validate'] && isset($global['pieform_' . $this->name] )) {
            if ($this->data['submit']) {
472
                $this->submitted = true;
Naomi Guyer's avatar
Naomi Guyer committed
473
                $this->submitvalue = isset($global['submit']) ? $global['submit'] : '';
474

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

481 482 483 484 485
                // If the form was submitted via the dropzone
                if (!empty($global['dropzone'])) {
                    $this->submitted_by_dropzone = true;
                }

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

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

            // 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
518
            if ($this->data['submit'] && !$this->has_errors()) {
519
                $submitted = false;
520
                foreach ($this->elementrefs as $name => $element) {
Nigel McNie's avatar
Nigel McNie committed
521
                    if (!empty($element['submitelement']) && isset($global[$element['name']])) {
522 523 524 525
                        if (!is_array($this->data['successcallback'])) {
                            $function = "{$this->data['successcallback']}_{$name}";
                            if (function_exists($function)) {
                                $function($this, $values);
526 527
                                log_debug('button-submit form ' . $function . ' should provide a redirect.');
                                return;
528
                            }
529 530 531
                        }
                    }
                }
Nigel McNie's avatar
Nigel McNie committed
532 533
                $function = $this->data['successcallback'];
                if (!$submitted && is_callable($function)) {
534 535 536
                    // 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
537 538 539
                    call_user_func_array($function, array($this, $values));
                    if ($this->data['dieaftersubmit']) {
                        if ($this->data['jsform']) {
540
                            $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
541 542 543 544 545 546 547 548
                        }
                        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...
549 550 551
                        if (isset($this->data['backoutaftersubmit'])) {
                            $this->data['backingout'] = TRUE;
                        }
Nigel McNie's avatar
Nigel McNie committed
552 553
                        return;
                    }
554
                }
555
                else if (!$submitted) {
556 557 558 559
                    throw new PieformException('No function registered to handle form submission for form "' . $this->name . '"');
                }
            }

560 561
            // If we get here, the form was submitted but failed validation

562
            // Auto focus the first element with an error if required
Nigel McNie's avatar
Nigel McNie committed
563
            if ($this->data['autofocus'] !== false) {
564 565
                $this->auto_focus_first_error();
            }
Nigel McNie's avatar
Nigel McNie committed
566 567 568 569 570 571

            // 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
572

Nigel McNie's avatar
Nigel McNie committed
573
            // If the form has been submitted by javascript, return json
574
            if ($this->submitted_by_js) {
Aaron Wells's avatar
Aaron Wells committed
575 576
                // TODO: get error messages in a 'third person' type form to
                // use here maybe? Would have to work for non js forms too. See
577 578 579 580 581 582
                // 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
583
                $message = $this->get_property('jserrormessage');
584
                $this->json_reply(PIEFORM_ERR, array('message' => $message));
585
            }
586 587 588 589
            else {
                global $SESSION;
                $SESSION->add_error_msg($this->get_property('errormessage'));
            }
590
        }
591 592 593 594 595 596 597 598 599 600
    }/*}}}*/

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

602 603 604 605 606 607 608 609 610
    /**
     * Returns the value of a submit button
     *
     * @return string
     */
    public function get_submitvalue() {/*{{{*/
        return $this->submitvalue;
    }/*}}}*/

611 612 613 614 615
    /**
     * 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
616
     * @param string The key of the property to return. If the property doesn't
617
     *               exist, null is returned
618 619
     * @return mixed
     */
620 621 622 623 624 625
    public function get_property($key) {/*{{{*/
        if (array_key_exists($key, $this->data)) {
            return $this->data[$key];
        }
        return null;
    }/*}}}*/
626

627 628 629 630 631 632 633 634 635 636 637 638 639
    /**
     * 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;
        }
    }

640
    /**
641
     * Returns whether the form has been submitted
642
     *
643
     * @return bool
644
     */
645 646 647
    public function is_submitted() {/*{{{*/
        return $this->submitted;
    }/*}}}*/
648 649

    /**
650
     * Returns whether the form has been submitted by javascript
651 652 653
     *
     * @return bool
     */
654 655 656
    public function submitted_by_js() {/*{{{*/
        return $this->submitted_by_js;
    }/*}}}*/
657

658 659 660 661 662
    /**
     * Returns the HTML for the <form...> tag
     *
     * @return string
     */
663 664 665 666 667
    public function get_form_tag() {/*{{{*/
        $result = '<form class="pieform';
        if ($this->has_errors()) {
            $result .= ' error';
        }
668
        if (isset($this->data['class'])) {
669
            $result .= ' ' . self::hsc($this->data['class']);
670
        }
671
        $result .= '"';
672
        foreach (array('name', 'method', 'action') as $attribute) {
673 674 675 676
            // empty action tags cause validation errors
            if($this->data[$attribute] !== ''){
                $result .= ' ' . $attribute . '="' . self::hsc($this->data[$attribute]) . '"';
            }
677 678 679 680 681 682
        }
        $result .= ' id="' . $this->name . '"';
        if ($this->fileupload) {
            $result .= ' enctype="multipart/form-data"';
        }
        $result .= '>';
683 684 685
        if (!empty($this->error)) {
            $result .= '<div class="error">' . $this->error . '</div>';
        }
686
        return $result;
687
    }/*}}}*/
688

689
    /**
Aaron Wells's avatar
Aaron Wells committed
690
     * Builds and returns the HTML for the form, using the chosen renderer or
691
     * template
692
     *
693
     * Note that the "action" attribute for the form tag is NOT HTML escaped
694 695 696 697
     * 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.
     *
698
     * @param boolean Whether to include the <form...></form> tags in the output
699 700
     * @return string The form as HTML
     */
701
    public function build($outputformtags=true) {/*{{{*/
702
        $result = '';
703

Aaron Wells's avatar
Aaron Wells committed
704
        // Builds the HTML each element (see the build_element_html method for
705 706
        // more information)
        foreach ($this->data['elements'] as &$element) {
707
            if ($element['type'] == 'fieldset' || $element['type'] == 'container') {
708 709 710 711 712 713 714 715
                foreach ($element['elements'] as &$subelement) {
                    $this->build_element_html($subelement);
                }
                unset($subelement);
            }
            else {
                $this->build_element_html($element);
            }
716
        }
717 718 719 720 721
        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();
722

Aaron Wells's avatar
Aaron Wells committed
723
            // $elements is a convenience variable that contains all of the form elements (minus fieldsets and
724 725 726 727 728 729
            // hidden elements)
            $elements = array();
            foreach ($this->elementrefs as $element) {
                if ($element['type'] != 'hidden') {
                    $elements[$element['name']] = $element;
                }
730 731
            }

732 733 734 735 736 737 738 739 740 741 742 743 744 745
            // 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);
746

747 748 749 750
            ob_start();

            if ($this->get_property('ignoretemplatenotices')) {
                $old_level = error_reporting(E_ALL & ~E_NOTICE);
751
            }
752 753 754 755 756 757 758 759 760 761 762 763

            $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();
764
        }
765 766 767 768 769
        else {
            // No template being used - instead use a renderer
            if ($outputformtags) {
                $result = $this->get_form_tag() . "\n";
            }
770

771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804
            $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
805
            if ($outputformtags) {
806
                $result .= "</form>\n";
Nigel McNie's avatar
Nigel McNie committed
807
            }
Nigel McNie's avatar
Nigel McNie committed
808
        }
809

Aaron Wells's avatar
Aaron Wells committed
810
        // Output the javascript to wire things up, but only if it is needed.
811
        // The two cases where it is needed is when:
Aaron Wells's avatar
Aaron Wells committed
812 813
        // 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
814
        // still active in the document
815
        // 2) The form is NOT a JS form, but has a presubmitcallback
816 817 818
        if ($outputformtags &&
            (($this->data['jsform'] && !$this->submitted)
             || (!$this->data['jsform'] && $this->data['presubmitcallback']))) {
Aaron Wells's avatar
Aaron Wells committed
819 820
            // Establish which buttons in the form are submit buttons. This is
            // used to detect which button was pressed to cause the form
821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838
            // 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'],
839
                'newIframeOnSubmit'     => $this->data['newiframeonsubmit'],
840
                'checkDirtyChange'      => $this->data['checkdirtychange'],
841
            ));
842
            $result .= "<script type=\"application/javascript\">new Pieform($data);</script>\n";
843
        }
844
        return $result;
845
    }/*}}}*/
846 847 848 849 850 851 852 853

    /**
     * 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.
     */
854
    public function get_value($element) {/*{{{*/
855 856 857
        if (isset($element['readonly']) && $element['readonly'] && isset($element['defaultvalue'])) {
            return $element['defaultvalue'];
        }
Nigel McNie's avatar
Nigel McNie committed
858
        $function = 'pieform_element_' . $element['type'] . '_get_value';
859
        if (function_exists($function)) {
Nigel McNie's avatar
Nigel McNie committed
860
            return $function($this, $element);
861
        }
Nigel McNie's avatar
Nigel McNie committed
862
        $global = ($this->data['method'] == 'get') ? $_GET : $_POST;
863 864 865
        // 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
866
        if (isset($element['value'])) {
867 868
            return $element['value'];
        }
869
        else if ($this->submitted && isset($global[$element['name']]) && $element['type'] != 'submit') {
870 871 872 873 874 875
            return $global[$element['name']];
        }
        else if (isset($element['defaultvalue'])) {
            return $element['defaultvalue'];
        }
        return null;
876
    }/*}}}*/
877 878 879 880

    /**
     * Retrieves a list of elements in the form.
     *
881
     * This flattens fieldsets/containers, and ignores the actual fieldset/container elements
882 883
     *
     * @return array The elements of the form
Aaron Wells's avatar
Aaron Wells committed
884
     */
885
    public function get_elements() {/*{{{*/
886
        $elements = array();
Nigel McNie's avatar
Nigel McNie committed
887
        foreach ($this->data['elements'] as $name => $element) {
888
            if ($element['type'] == 'fieldset' || $element['type'] == 'container') {
889 890 891 892 893 894 895 896 897
                foreach ($element['elements'] as $subelement) {
                    $elements[] = $subelement;
                }
            }
            else {
                $elements[] = $element;
            }
        }
        return $elements;
898 899
    }/*}}}*/

900 901 902 903
    /**
     * Returns the element with the given name. Throws a PieformException if the
     * element cannot be found.
     *
904
     * Fieldset and container elements are ignored. This might change if a valid case for
905 906 907 908 909 910
     * 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
     */
911 912 913
    public function get_element($name) {/*{{{*/
        if (isset($this->elementrefs[$name])) {
            return $this->elementrefs[$name];
914 915
        }

916 917
        throw new PieformException('Element "' . $name . '" cannot be found');
    }/*}}}*/
918

919 920 921 922 923 924 925 926 927 928 929 930 931 932
    /**
     * 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));
        }
    }

933
    /**
934
     * Sends a message back to a jsform.
935
     *
Aaron Wells's avatar
Aaron Wells committed
936 937
     * 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
938
     * first parameter of this method.
939
     *
Aaron Wells's avatar
Aaron Wells committed
940 941
     * - 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
942
     *   choice of the application using pieforms
Aaron Wells's avatar
Aaron Wells committed
943
     * - A message. This is just a string that can be used as a status message,
944
     *   e.g. 'Form failed submission'
Aaron Wells's avatar
Aaron Wells committed
945 946
     * - 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
947
     *   after successful submission if you want
948
     */
949 950 951 952 953
    public function json_reply($returncode, $data=array(), $replacehtml=null) {/*{{{*/
        if (is_string($data)) {
            $data = array(
                'message' => $data,
            );
954
        }
955 956 957
        $data['returnCode'] = intval($returncode);
        if ($replacehtml === null) {
            $data['replaceHTML'] = $this->build();
958
        }
959 960
        else if (is_string($replacehtml)) {
            $data['replaceHTML'] = $replacehtml;
961
        }
962 963 964
        if (isset($this->hashedfields)) {
            $data['fieldnames'] = $this->hashedfields;
        }
965

Nigel McNie's avatar
Nigel McNie committed
966
        $result = json_encode($data);
967 968 969 970
        if ($this->submitted_by_dropzone) {
            echo $result;
            exit;
        }
Nigel McNie's avatar
Nigel McNie committed
971
        echo <<<EOF
972
<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
973 974
EOF;
        exit;
975
    }/*}}}*/
976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994

    /**
     * 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
     */
995
    public function get_error($name) {/*{{{*/
996 997
        $element = $this->get_element($name);
        return isset($element['error']);
998
    }/*}}}*/
999 1000 1001 1002 1003

    /**
     * Marks a field has having an error.
     *
     * This method should be used to set an error on an element in a custom
1004
     * validation function, if one has occurred.
1005
     *
1006 1007 1008
     * @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
1009 1010
     * @throws PieformException  If the element could not be found
     */
1011
    public function set_error($name, $message, $isescaped = true) {/*{{{*/
1012 1013 1014 1015
        if (is_null($name) && !empty($message)) {
            $this->error = $message;
            return;
        }
1016
        foreach ($this->data['elements'] as $key => &$element) {
1017
            if ($element['type'] == 'fieldset' || $element['type'] == 'container') {
1018 1019 1020
                foreach ($element['elements'] as &$subelement) {
                    if ($subelement['name'] == $name) {
                        $subelement['error'] = $message;
1021
                        $subelement['isescaped'] = ($isescaped) ? true : false;
1022
                        $this->data['haserror'] = true;
1023 1024 1025 1026 1027
                        return;
                    }
                }
            }
            else {
1028
                if ($key == $name) {
1029
                    $element['error'] = $message;
1030
                    $element['isescaped'] = ($isescaped) ? true : false;
1031
                    $this->data['haserror'] = true;
1032 1033 1034 1035 1036
                    return;
                }
            }
        }
        throw new PieformException('Element "' . $name . '" could not be found');