1<?php
2
3namespace dokuwiki\Form;
4
5use dokuwiki\Extension\Event;
6
7/**
8 * Class Form
9 *
10 * Represents the whole Form. This is what you work on, and add Elements to
11 *
12 * @package dokuwiki\Form
13 */
14class Form extends Element
15{
16    /**
17     * @var array name value pairs for hidden values
18     */
19    protected $hidden = [];
20
21    /**
22     * @var Element[] the elements of the form
23     */
24    protected $elements = [];
25
26    /**
27     * Creates a new, empty form with some default attributes
28     *
29     * @param array $attributes
30     * @param bool  $unsafe     if true, then the security token is ommited
31     */
32    public function __construct($attributes = [], $unsafe = false)
33    {
34        global $ID;
35
36        parent::__construct('form', $attributes);
37
38        // use the current URL as default action
39        if (!$this->attr('action')) {
40            $get = $_GET;
41            if (isset($get['id'])) unset($get['id']);
42            $self = wl($ID, $get, false, '&'); //attributes are escaped later
43            $this->attr('action', $self);
44        }
45
46        // post is default
47        if (!$this->attr('method')) {
48            $this->attr('method', 'post');
49        }
50
51        // we like UTF-8
52        if (!$this->attr('accept-charset')) {
53            $this->attr('accept-charset', 'utf-8');
54        }
55
56        // add the security token by default
57        if (!$unsafe) {
58            $this->setHiddenField('sectok', getSecurityToken());
59        }
60
61        // identify this as a new form based form in HTML
62        $this->addClass('doku_form');
63    }
64
65    /**
66     * Sets a hidden field
67     *
68     * @param string $name
69     * @param string $value
70     * @return $this
71     */
72    public function setHiddenField($name, $value)
73    {
74        $this->hidden[$name] = $value;
75        return $this;
76    }
77
78    #region element query function
79
80    /**
81     * Returns the numbers of elements in the form
82     *
83     * @return int
84     */
85    public function elementCount()
86    {
87        return count($this->elements);
88    }
89
90    /**
91     * Get the position of the element in the form or false if it is not in the form
92     *
93     * Warning: This function may return Boolean FALSE, but may also return a non-Boolean value which evaluates
94     * to FALSE. Please read the section on Booleans for more information. Use the === operator for testing the
95     * return value of this function.
96     *
97     * @param Element $element
98     *
99     * @return false|int
100     */
101    public function getElementPosition(Element $element)
102    {
103        return array_search($element, $this->elements, true);
104    }
105
106    /**
107     * Returns a reference to the element at a position.
108     * A position out-of-bounds will return either the
109     * first (underflow) or last (overflow) element.
110     *
111     * @param int $pos
112     * @return Element
113     */
114    public function getElementAt($pos)
115    {
116        if ($pos < 0) $pos = count($this->elements) + $pos;
117        if ($pos < 0) $pos = 0;
118        if ($pos >= count($this->elements)) $pos = count($this->elements) - 1;
119        return $this->elements[$pos];
120    }
121
122    /**
123     * Gets the position of the first of a type of element
124     *
125     * @param string $type Element type to look for.
126     * @param int $offset search from this position onward
127     * @return false|int position of element if found, otherwise false
128     */
129    public function findPositionByType($type, $offset = 0)
130    {
131        $len = $this->elementCount();
132        for ($pos = $offset; $pos < $len; $pos++) {
133            if ($this->elements[$pos]->getType() == $type) {
134                return $pos;
135            }
136        }
137        return false;
138    }
139
140    /**
141     * Gets the position of the first element matching the attribute
142     *
143     * @param string $name Name of the attribute
144     * @param string $value Value the attribute should have
145     * @param int $offset search from this position onward
146     * @return false|int position of element if found, otherwise false
147     */
148    public function findPositionByAttribute($name, $value, $offset = 0)
149    {
150        $len = $this->elementCount();
151        for ($pos = $offset; $pos < $len; $pos++) {
152            if ($this->elements[$pos]->attr($name) == $value) {
153                return $pos;
154            }
155        }
156        return false;
157    }
158
159    #endregion
160
161    #region Element positioning functions
162
163    /**
164     * Adds or inserts an element to the form
165     *
166     * @param Element $element
167     * @param int $pos 0-based position in the form, -1 for at the end
168     * @return Element
169     */
170    public function addElement(Element $element, $pos = -1)
171    {
172        if (is_a($element, '\dokuwiki\Form\Form')) throw new \InvalidArgumentException(
173            'You can\'t add a form to a form'
174        );
175        if ($pos < 0) {
176            $this->elements[] = $element;
177        } else {
178            array_splice($this->elements, $pos, 0, [$element]);
179        }
180        return $element;
181    }
182
183    /**
184     * Replaces an existing element with a new one
185     *
186     * @param Element $element the new element
187     * @param int $pos 0-based position of the element to replace
188     */
189    public function replaceElement(Element $element, $pos)
190    {
191        if (is_a($element, '\dokuwiki\Form\Form')) throw new \InvalidArgumentException(
192            'You can\'t add a form to a form'
193        );
194        array_splice($this->elements, $pos, 1, [$element]);
195    }
196
197    /**
198     * Remove an element from the form completely
199     *
200     * @param int $pos 0-based position of the element to remove
201     */
202    public function removeElement($pos)
203    {
204        array_splice($this->elements, $pos, 1);
205    }
206
207    #endregion
208
209    #region Element adding functions
210
211    /**
212     * Adds a text input field
213     *
214     * @param string $name
215     * @param string $label
216     * @param int $pos
217     * @return InputElement
218     */
219    public function addTextInput($name, $label = '', $pos = -1)
220    {
221        return $this->addElement(new InputElement('text', $name, $label), $pos);
222    }
223
224    /**
225     * Adds a password input field
226     *
227     * @param string $name
228     * @param string $label
229     * @param int $pos
230     * @return InputElement
231     */
232    public function addPasswordInput($name, $label = '', $pos = -1)
233    {
234        return $this->addElement(new InputElement('password', $name, $label), $pos);
235    }
236
237    /**
238     * Adds a radio button field
239     *
240     * @param string $name
241     * @param string $label
242     * @param int $pos
243     * @return CheckableElement
244     */
245    public function addRadioButton($name, $label = '', $pos = -1)
246    {
247        return $this->addElement(new CheckableElement('radio', $name, $label), $pos);
248    }
249
250    /**
251     * Adds a checkbox field
252     *
253     * @param string $name
254     * @param string $label
255     * @param int $pos
256     * @return CheckableElement
257     */
258    public function addCheckbox($name, $label = '', $pos = -1)
259    {
260        return $this->addElement(new CheckableElement('checkbox', $name, $label), $pos);
261    }
262
263    /**
264     * Adds a dropdown field
265     *
266     * @param string $name
267     * @param array $options
268     * @param string $label
269     * @param int $pos
270     * @return DropdownElement
271     */
272    public function addDropdown($name, $options, $label = '', $pos = -1)
273    {
274        return $this->addElement(new DropdownElement($name, $options, $label), $pos);
275    }
276
277    /**
278     * Adds a textarea field
279     *
280     * @param string $name
281     * @param string $label
282     * @param int $pos
283     * @return TextareaElement
284     */
285    public function addTextarea($name, $label = '', $pos = -1)
286    {
287        return $this->addElement(new TextareaElement($name, $label), $pos);
288    }
289
290    /**
291     * Adds a simple button, escapes the content for you
292     *
293     * @param string $name
294     * @param string $content
295     * @param int $pos
296     * @return Element
297     */
298    public function addButton($name, $content, $pos = -1)
299    {
300        return $this->addElement(new ButtonElement($name, hsc($content)), $pos);
301    }
302
303    /**
304     * Adds a simple button, allows HTML for content
305     *
306     * @param string $name
307     * @param string $html
308     * @param int $pos
309     * @return Element
310     */
311    public function addButtonHTML($name, $html, $pos = -1)
312    {
313        return $this->addElement(new ButtonElement($name, $html), $pos);
314    }
315
316    /**
317     * Adds a label referencing another input element, escapes the label for you
318     *
319     * @param string $label
320     * @param string $for
321     * @param int $pos
322     * @return Element
323     */
324    public function addLabel($label, $for = '', $pos = -1)
325    {
326        return $this->addLabelHTML(hsc($label), $for, $pos);
327    }
328
329    /**
330     * Adds a label referencing another input element, allows HTML for content
331     *
332     * @param string $content
333     * @param string|Element $for
334     * @param int $pos
335     * @return Element
336     */
337    public function addLabelHTML($content, $for = '', $pos = -1)
338    {
339        $element = new LabelElement($content);
340
341        if (is_a($for, '\dokuwiki\Form\Element')) {
342            /** @var Element $for */
343            $for = $for->id();
344        }
345        $for = (string) $for;
346        if ($for !== '') {
347            $element->attr('for', $for);
348        }
349
350        return $this->addElement($element, $pos);
351    }
352
353    /**
354     * Add fixed HTML to the form
355     *
356     * @param string $html
357     * @param int $pos
358     * @return HTMLElement
359     */
360    public function addHTML($html, $pos = -1)
361    {
362        return $this->addElement(new HTMLElement($html), $pos);
363    }
364
365    /**
366     * Add a closed HTML tag to the form
367     *
368     * @param string $tag
369     * @param int $pos
370     * @return TagElement
371     */
372    public function addTag($tag, $pos = -1)
373    {
374        return $this->addElement(new TagElement($tag), $pos);
375    }
376
377    /**
378     * Add an open HTML tag to the form
379     *
380     * Be sure to close it again!
381     *
382     * @param string $tag
383     * @param int $pos
384     * @return TagOpenElement
385     */
386    public function addTagOpen($tag, $pos = -1)
387    {
388        return $this->addElement(new TagOpenElement($tag), $pos);
389    }
390
391    /**
392     * Add a closing HTML tag to the form
393     *
394     * Be sure it had been opened before
395     *
396     * @param string $tag
397     * @param int $pos
398     * @return TagCloseElement
399     */
400    public function addTagClose($tag, $pos = -1)
401    {
402        return $this->addElement(new TagCloseElement($tag), $pos);
403    }
404
405    /**
406     * Open a Fieldset
407     *
408     * @param string $legend
409     * @param int $pos
410     * @return FieldsetOpenElement
411     */
412    public function addFieldsetOpen($legend = '', $pos = -1)
413    {
414        return $this->addElement(new FieldsetOpenElement($legend), $pos);
415    }
416
417    /**
418     * Close a fieldset
419     *
420     * @param int $pos
421     * @return TagCloseElement
422     */
423    public function addFieldsetClose($pos = -1)
424    {
425        return $this->addElement(new FieldsetCloseElement(), $pos);
426    }
427
428    #endregion
429
430    /**
431     * Adjust the elements so that fieldset open and closes are matching
432     */
433    protected function balanceFieldsets()
434    {
435        $lastclose = 0;
436        $isopen = false;
437        $len = count($this->elements);
438
439        for ($pos = 0; $pos < $len; $pos++) {
440            $type = $this->elements[$pos]->getType();
441            if ($type == 'fieldsetopen') {
442                if ($isopen) {
443                    //close previous fieldset
444                    $this->addFieldsetClose($pos);
445                    $lastclose = $pos + 1;
446                    $pos++;
447                    $len++;
448                }
449                $isopen = true;
450            } elseif ($type == 'fieldsetclose') {
451                if (!$isopen) {
452                    // make sure there was a fieldsetopen
453                    // either right after the last close or at the begining
454                    $this->addFieldsetOpen('', $lastclose);
455                    $len++;
456                    $pos++;
457                }
458                $lastclose = $pos;
459                $isopen = false;
460            }
461        }
462
463        // close open fieldset at the end
464        if ($isopen) {
465            $this->addFieldsetClose();
466        }
467    }
468
469    /**
470     * The HTML representation of the whole form
471     *
472     * @param string $eventName  (optional) name of the event: FORM_{$name}_OUTPUT
473     * @return string
474     */
475    public function toHTML($eventName = null)
476    {
477        $this->balanceFieldsets();
478
479        // trigger event to provide an opportunity to modify this form
480        if (isset($eventName)) {
481            $eventName = 'FORM_' . strtoupper($eventName) . '_OUTPUT';
482            Event::createAndTrigger($eventName, $this, null, false);
483        }
484
485        $html = '<form ' . buildAttributes($this->attrs()) . '>';
486
487        foreach ($this->hidden as $name => $value) {
488            $html .= '<input type="hidden" name="' . $name . '" value="' . formText($value) . '" />';
489        }
490
491        foreach ($this->elements as $element) {
492            $html .= $element->toHTML();
493        }
494
495        $html .= '</form>';
496
497        return $html;
498    }
499}
500