Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.74% covered (warning)
87.74%
415 / 473
70.27% covered (warning)
70.27%
26 / 37
CRAP
0.00% covered (danger)
0.00%
0 / 1
Form
87.74% covered (warning)
87.74%
415 / 473
70.27% covered (warning)
70.27%
26 / 37
173.11
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 setFormId
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getDisplayString
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 isEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 useCaptcha
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reportReferrer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reportUserAgent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 showOnlyForLoggedUsers
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFormElementConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRecipient
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 getTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHelp
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getEmailSubject
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
4
 getSubmitResponse
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getEmailFromAddress
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getEmailFromName
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 formatEmailMessage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 mapRequestParamsToFieldValues
55.26% covered (warning)
55.26%
21 / 38
0.00% covered (danger)
0.00%
0 / 1
35.15
 getInputFilter
100.00% covered (success)
100.00%
64 / 64
100.00% covered (success)
100.00%
1 / 1
11
 getFormConfig
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
3.24
 mergeLocalConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseConfig
88.78% covered (warning)
88.78%
87 / 98
0.00% covered (danger)
0.00%
0 / 1
30.19
 getElementOptions
69.57% covered (warning)
69.57%
16 / 23
0.00% covered (danger)
0.00%
0 / 1
5.70
 getElementOptionGroups
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
5.02
 getFormSettingFields
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
1
 getFormElementSettingFields
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
1
 getProtectedFieldNames
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 buildForm
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 getFormElement
92.86% covered (success)
92.86%
78 / 84
0.00% covered (danger)
0.00%
0 / 1
24.21
 getFormElementClass
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getValidationMessage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getFormElements
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 getElementId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPrimaryHandler
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getSecondaryHandlers
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getFormId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sanitizePrefill
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * Configurable form.
5 *
6 * PHP version 8
7 *
8 * Copyright (C) The National Library of Finland 2018-2021.
9 *
10 * This program is free software; you can redistribute it and/or modify
11 * it under the terms of the GNU General Public License version 2,
12 * as published by the Free Software Foundation.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License
20 * along with this program; if not, write to the Free Software
21 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
22 *
23 * @category VuFind
24 * @package  Form
25 * @author   Samuli Sillanpää <samuli.sillanpaa@helsinki.fi>
26 * @author   Ere Maijala <ere.maijala@helsinki.fi>
27 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
28 * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
29 */
30
31namespace VuFind\Form;
32
33use Laminas\InputFilter\InputFilter;
34use Laminas\InputFilter\InputFilterInterface;
35use Laminas\Validator\Callback;
36use Laminas\Validator\EmailAddress;
37use Laminas\Validator\Identical;
38use Laminas\Validator\NotEmpty;
39use Laminas\View\HelperPluginManager;
40use VuFind\Config\YamlReader;
41use VuFind\Form\Handler\HandlerInterface;
42use VuFind\Form\Handler\PluginManager as HandlerManager;
43
44use function count;
45use function in_array;
46use function is_array;
47
48/**
49 * Configurable form.
50 *
51 * @category VuFind
52 * @package  Form
53 * @author   Samuli Sillanpää <samuli.sillanpaa@helsinki.fi>
54 * @author   Ere Maijala <ere.maijala@helsinki.fi>
55 * @license  http://opensource.org/licenses/gpl-2.0.php GNU General Public License
56 * @link     https://vufind.org/wiki/development:plugins:controllers Wiki
57 */
58class Form extends \Laminas\Form\Form implements
59    \VuFind\I18n\Translator\TranslatorAwareInterface
60{
61    use \VuFind\I18n\Translator\TranslatorAwareTrait;
62
63    /**
64     * Input filter
65     *
66     * @var InputFilter
67     */
68    protected $inputFilter;
69
70    /**
71     * Default, untranslated validation messages
72     *
73     * @var array
74     */
75    protected $messages = [
76        'empty' => 'This field is required',
77        'invalid_email' => 'Email address is invalid',
78    ];
79
80    /**
81     * VuFind main configuration
82     *
83     * @var array
84     */
85    protected $vufindConfig;
86
87    /**
88     * Default form configuration (from config.ini > Feedback)
89     *
90     * @var array
91     */
92    protected $defaultFormConfig;
93
94    /**
95     * Form element configuration
96     *
97     * @var array
98     */
99    protected $formElementConfig = [];
100
101    /**
102     * Form configuration
103     *
104     * @var array
105     */
106    protected $formConfig;
107
108    /**
109     * YAML reader
110     *
111     * @var YamlReader
112     */
113    protected $yamlReader;
114
115    /**
116     * View helper manager.
117     *
118     * @var HelperPluginManager
119     */
120    protected $viewHelperManager;
121
122    /**
123     * Handler plugin manager
124     *
125     * @var HandlerManager
126     */
127    protected $handlerManager;
128
129    /**
130     * Constructor
131     *
132     * @param YamlReader          $yamlReader        YAML reader
133     * @param HelperPluginManager $viewHelperManager View helper manager
134     * @param HandlerManager      $handlerManager    Handler plugin manager
135     * @param array               $config            VuFind main configuration
136     * (optional)
137     *
138     * @throws \Exception
139     */
140    public function __construct(
141        YamlReader $yamlReader,
142        HelperPluginManager $viewHelperManager,
143        HandlerManager $handlerManager,
144        array $config = null
145    ) {
146        parent::__construct();
147
148        $this->vufindConfig = $config;
149        $this->defaultFormConfig = $config['Feedback'] ?? null;
150        $this->yamlReader = $yamlReader;
151        $this->viewHelperManager = $viewHelperManager;
152        $this->handlerManager = $handlerManager;
153    }
154
155    /**
156     * Set form id
157     *
158     * @param string $formId  Form id
159     * @param array  $params  Additional form parameters.
160     * @param array  $prefill Prefill form with these values.
161     *
162     * @return void
163     * @throws \Exception
164     */
165    public function setFormId($formId, $params = [], $prefill = [])
166    {
167        if (!$config = $this->getFormConfig($formId)) {
168            throw new \VuFind\Exception\RecordMissing("Form '$formId' not found");
169        }
170
171        $this->formElementConfig
172            = $this->parseConfig($formId, $config, $params, $prefill);
173
174        $this->buildForm();
175    }
176
177    /**
178     * Get display string.
179     *
180     * @param string $translationKey Translation key
181     * @param bool   $escape         Whether to escape the output.
182     * Default behaviour is to escape when the translation key does
183     * not end with '_html'.
184     *
185     * @return string
186     */
187    public function getDisplayString($translationKey, $escape = null)
188    {
189        $escape ??= !str_ends_with($translationKey, '_html');
190        $helper = $this->viewHelperManager->get($escape ? 'transEsc' : 'translate');
191        return $helper($translationKey);
192    }
193
194    /**
195     * Check if form enabled.
196     *
197     * @return bool
198     */
199    public function isEnabled()
200    {
201        // Enabled unless explicitly disabled
202        return ($this->formConfig['enabled'] ?? true) === true;
203    }
204
205    /**
206     * Check if the form should use Captcha validation (if supported)
207     *
208     * @return bool
209     */
210    public function useCaptcha()
211    {
212        return (bool)($this->formConfig['useCaptcha'] ?? true);
213    }
214
215    /**
216     * Check if the form should report referrer url
217     *
218     * @return bool
219     */
220    public function reportReferrer()
221    {
222        return (bool)($this->formConfig['reportReferrer'] ?? false);
223    }
224
225    /**
226     * Check if the form should report browser's user agent
227     *
228     * @return bool
229     */
230    public function reportUserAgent()
231    {
232        return (bool)($this->formConfig['reportUserAgent'] ?? false);
233    }
234
235    /**
236     * Check if form is available only for logged users.
237     *
238     * @return bool
239     */
240    public function showOnlyForLoggedUsers()
241    {
242        return !empty($this->formConfig['onlyForLoggedUsers']);
243    }
244
245    /**
246     * Return form element configuration.
247     *
248     * @return array
249     */
250    public function getFormElementConfig(): array
251    {
252        return $this->formElementConfig;
253    }
254
255    /**
256     * Return form recipient(s).
257     *
258     * @param array $postParams Posted form data
259     *
260     * @return array of recipients, each consisting of an array with
261     * name, email or null if not configured
262     *
263     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
264     */
265    public function getRecipient($postParams = null)
266    {
267        $recipient = $this->formConfig['recipient'] ?? [null];
268        $recipients = isset($recipient['email']) || isset($recipient['name'])
269            ? [$recipient] : $recipient;
270
271        foreach ($recipients as &$recipient) {
272            $recipient['email'] ??= $this->defaultFormConfig['recipient_email'] ?? null;
273            $recipient['name'] ??= $this->defaultFormConfig['recipient_name'] ?? null;
274        }
275
276        return $recipients;
277    }
278
279    /**
280     * Return form title.
281     *
282     * @return string
283     */
284    public function getTitle()
285    {
286        return $this->formConfig['title'] ?? null;
287    }
288
289    /**
290     * Return form help texts.
291     *
292     * @return array|null
293     */
294    public function getHelp()
295    {
296        return $this->formConfig['help'] ?? null;
297    }
298
299    /**
300     * Return form email message subject.
301     *
302     * Replaces any placeholders for form field values or labels with the submitted
303     * values.
304     *
305     * @param array $postParams Posted form data
306     *
307     * @return string
308     */
309    public function getEmailSubject($postParams)
310    {
311        $subject = 'VuFind Feedback';
312
313        if (!empty($this->formConfig['emailSubject'])) {
314            $subject = $this->formConfig['emailSubject'];
315        } elseif (!empty($this->defaultFormConfig['email_subject'])) {
316            $subject = $this->defaultFormConfig['email_subject'];
317        }
318
319        $mappings = [];
320        foreach ($this->mapRequestParamsToFieldValues($postParams) as $field) {
321            // Use translated value as default for backward compatibility:
322            $mappings["%%{$field['name']}%%"]
323                = $mappings["%%translatedValue:{$field['name']}%%"]
324                    = implode(
325                        ', ',
326                        array_map(
327                            [$this, 'translate'],
328                            (array)($field['value'] ?? [])
329                        )
330                    );
331            $mappings["%%value:{$field['name']}%%"]
332                = implode(', ', (array)($field['value'] ?? []));
333            $mappings["%%label:{$field['name']}%%"]
334                = implode(', ', (array)($field['valueLabel'] ?? []));
335            $mappings["%%translatedLabel:{$field['name']}%%"] = implode(
336                ', ',
337                array_map(
338                    [$this, 'translate'],
339                    (array)($field['valueLabel'] ?? [])
340                )
341            );
342        }
343
344        return trim($this->translate($subject, $mappings));
345    }
346
347    /**
348     * Return response that is shown after successful form submit.
349     *
350     * @return string
351     */
352    public function getSubmitResponse()
353    {
354        return !empty($this->formConfig['response'])
355            ? $this->formConfig['response']
356            : 'feedback_response';
357    }
358
359    /**
360     * Return email from address
361     *
362     * @return string
363     */
364    public function getEmailFromAddress(): string
365    {
366        return !empty($this->formConfig['emailFrom']['email'])
367            ? $this->formConfig['emailFrom']['email']
368            : '';
369    }
370
371    /**
372     * Return email from name
373     *
374     * @return string
375     */
376    public function getEmailFromName(): string
377    {
378        return !empty($this->formConfig['emailFrom']['name'])
379            ? $this->formConfig['emailFrom']['name']
380            : '';
381    }
382
383    /**
384     * Format email message.
385     *
386     * @param array $requestParams Request parameters
387     *
388     * @return array Array with template parameters and template name.
389     *
390     * @deprecated Use mapRequestParamsToFieldValues
391     */
392    public function formatEmailMessage(array $requestParams = [])
393    {
394        return [
395            $this->mapRequestParamsToFieldValues($requestParams),
396            'Email/form.phtml',
397        ];
398    }
399
400    /**
401     * Map request parameters to field values
402     *
403     * @param array $requestParams Request parameters
404     *
405     * @return array
406     */
407    public function mapRequestParamsToFieldValues(array $requestParams): array
408    {
409        $params = [];
410        foreach ($this->getFormElementConfig() as $el) {
411            $type = $el['type'];
412            if ($type === 'submit') {
413                continue;
414            }
415            $value = $requestParams[$el['name']] ?? null;
416            $valueLabel = null;
417
418            if (in_array($type, ['radio', 'select'])) {
419                $option = null;
420                if (isset($el['options'])) {
421                    $option = $el['options'][$value] ?? null;
422                } elseif (isset($el['optionGroups'])) {
423                    foreach ($el['optionGroups'] as $group) {
424                        if (isset($group['options'][$value])) {
425                            $option = $group['options'][$value];
426                            break;
427                        }
428                    }
429                }
430                if (null === $option) {
431                    $value = null;
432                    $valueLabel = null;
433                } else {
434                    $value = $option['value'];
435                    $valueLabel = $option['label'];
436                }
437            } elseif ($type === 'checkbox' && !empty($value)) {
438                $labels = [];
439                $values = [];
440                foreach ($value as $val) {
441                    $option = $el['options'][$val] ?? null;
442                    if (null === $option) {
443                        continue;
444                    }
445                    $labels[] = $option['label'];
446                    $values[] = $option['value'];
447                }
448                $value = $values;
449                $valueLabel = $labels;
450            } elseif ($type === 'date' && !empty($value)) {
451                $format = $el['format']
452                    ?? $this->vufindConfig['Site']['displayDateFormat'] ?? 'Y-m-d';
453                $date = strtotime($value);
454                $value = date($format, $date);
455            }
456            $params[] = $el + compact('value', 'valueLabel');
457        }
458
459        return $params;
460    }
461
462    /**
463     * Retrieve input filter used by this form
464     *
465     * @return InputFilterInterface
466     */
467    public function getInputFilter(): InputFilterInterface
468    {
469        if ($this->inputFilter) {
470            return $this->inputFilter;
471        }
472
473        $inputFilter = new InputFilter();
474
475        $validators = [
476            'email' => [
477                'name' => EmailAddress::class,
478                'options' => [
479                    'message' => $this->getValidationMessage('invalid_email'),
480                ],
481            ],
482            'notEmpty' => [
483                'name' => NotEmpty::class,
484                'options' => [
485                    'message' => [
486                        NotEmpty::IS_EMPTY => $this->getValidationMessage('empty'),
487                    ],
488                ],
489            ],
490        ];
491
492        $elementObjects = $this->getElements();
493        foreach ($this->getFormElementConfig() as $el) {
494            $isCheckbox = $el['type'] === 'checkbox';
495            $requireOne = $isCheckbox && ($el['requireOne'] ?? false);
496            $required = $el['required'] ?? $requireOne;
497
498            $fieldValidators = [];
499            if ($required || $requireOne) {
500                $fieldValidators[] = $validators['notEmpty'];
501            }
502            if ($isCheckbox) {
503                if ($requireOne) {
504                    $fieldValidators[] = [
505                        'name' => Callback::class,
506                        'options' => [
507                            'callback' => function ($value, $context) use ($el) {
508                                return
509                                    !empty(
510                                        array_intersect(
511                                            array_keys($el['options']),
512                                            $value
513                                        )
514                                    );
515                            },
516                         ],
517                    ];
518                } elseif ($required) {
519                    $fieldValidators[] = [
520                        'name' => Identical::class,
521                        'options' => [
522                            'message' => [
523                                Identical::MISSING_TOKEN
524                                => $this->getValidationMessage('empty'),
525                            ],
526                            'strict' => true,
527                            'token' => array_keys($el['options']),
528                        ],
529                    ];
530                }
531            }
532
533            if ($el['type'] === 'email') {
534                $fieldValidators[] = $validators['email'];
535            }
536
537            if (in_array($el['type'], ['checkbox', 'radio', 'select'])) {
538                // Add InArray validator from element object instance
539                $elementObject = $elementObjects[$el['name']];
540                $elementSpec = $elementObject->getInputSpecification();
541                $fieldValidators
542                    = array_merge($fieldValidators, $elementSpec['validators']);
543            }
544
545            $inputFilter->add(
546                [
547                    'name' => $el['name'],
548                    'required' => $required,
549                    'validators' => $fieldValidators,
550                ]
551            );
552        }
553
554        $this->inputFilter = $inputFilter;
555        return $this->inputFilter;
556    }
557
558    /**
559     * Get form configuration
560     *
561     * @param string $formId Form id
562     *
563     * @return mixed null|array
564     * @throws \Exception
565     */
566    protected function getFormConfig($formId = null)
567    {
568        $confName = 'FeedbackForms.yaml';
569        $config = $this->yamlReader->get($confName, false, true);
570        $localConfig = $this->yamlReader->get($confName, true, true);
571
572        if (!$formId) {
573            $formId = $localConfig['default'] ?? $config['default'] ?? null;
574            if (!$formId) {
575                return null;
576            }
577        }
578
579        $config = $config['forms'][$formId] ?? null;
580        $localConfig = $localConfig['forms'][$formId] ?? null;
581
582        return $this->mergeLocalConfig($config, $localConfig);
583    }
584
585    /**
586     * Merge local configuration into default configuration.
587     *
588     * @param array  $config      Default configuration
589     * @param ?array $localConfig Local configuration
590     *
591     * @return array
592     */
593    protected function mergeLocalConfig($config, $localConfig = null)
594    {
595        return $localConfig ?? $config;
596    }
597
598    /**
599     * Parse form configuration.
600     *
601     * @param string $formId  Form id
602     * @param array  $config  Configuration
603     * @param array  $params  Additional form parameters.
604     * @param array  $prefill Prefill form with these values.
605     *
606     * @return array
607     */
608    protected function parseConfig($formId, $config, $params, $prefill)
609    {
610        $formConfig = [
611           'id' => $formId,
612           'title' => !empty($config['name']) ?: $formId,
613        ];
614
615        foreach ($this->getFormSettingFields() as $key) {
616            if (isset($config[$key])) {
617                $formConfig[$key] = $config[$key];
618            }
619        }
620
621        $this->formConfig = $formConfig;
622
623        $prefill = $this->sanitizePrefill($prefill);
624
625        $elements = [];
626        $configuredElements = $this->getFormElements($config);
627
628        // Defaults for sender contact name & email fields:
629        $senderName = [
630            'name' => 'name',
631            'type' => 'text',
632            'label' => $this->translate('feedback_name'),
633            'group' => '__sender__',
634        ];
635        $senderEmail = [
636            'name' => 'email',
637            'type' => 'email',
638            'label' => $this->translate('feedback_email'),
639            'group' => '__sender__',
640        ];
641        if ($formConfig['senderInfoRequired'] ?? false) {
642            $senderEmail['required'] = $senderName['required'] = true;
643        }
644        if ($formConfig['senderNameRequired'] ?? false) {
645            $senderName['required'] = true;
646        }
647        if ($formConfig['senderEmailRequired'] ?? false) {
648            $senderEmail['required'] = true;
649        }
650
651        foreach ($configuredElements as $el) {
652            $element = [];
653
654            $required = ['type', 'name'];
655            $optional = $this->getFormElementSettingFields();
656            foreach (
657                array_merge($required, $optional) as $field
658            ) {
659                if (!isset($el[$field])) {
660                    continue;
661                }
662                $value = $el[$field];
663                $element[$field] = $value;
664            }
665
666            if (
667                in_array($element['type'], ['checkbox', 'radio'])
668                && !isset($element['group'])
669            ) {
670                $element['group'] = $element['name'];
671            }
672
673            $element['label'] = $el['label'] ?? '';
674
675            $elementType = $element['type'];
676            if (in_array($elementType, ['checkbox', 'radio', 'select'])) {
677                if ($options = $this->getElementOptions($el)) {
678                    $element['options'] = $options;
679                } elseif ($optionGroups = $this->getElementOptionGroups($el)) {
680                    $element['optionGroups'] = $optionGroups;
681                }
682            }
683
684            $settings = [];
685            foreach ($el['settings'] ?? [] as $setting) {
686                if (!is_array($setting)) {
687                    continue;
688                }
689                // Allow both [key => value] and [key, value]:
690                if (count($setting) !== 2) {
691                    reset($setting);
692                    $settingId = trim(key($setting));
693                    $settingVal = trim(current($setting));
694                } else {
695                    $settingId = trim($setting[0]);
696                    $settingVal = trim($setting[1]);
697                }
698                $settings[$settingId] = $settingVal;
699            }
700            $element['settings'] = $settings;
701
702            // Merge sender fields with any existing field definitions:
703            if ('name' === $element['name']) {
704                $element = array_replace_recursive($senderName, $element);
705                $senderName = null;
706            } elseif ('email' === $element['name']) {
707                $element = array_replace_recursive($senderEmail, $element);
708                $senderEmail = null;
709            }
710
711            if ($elementType == 'textarea') {
712                if (!isset($element['settings']['rows'])) {
713                    $element['settings']['rows'] = 8;
714                }
715            }
716
717            if (!empty($prefill[$element['name']])) {
718                $element['settings']['value'] = $prefill[$element['name']];
719            }
720
721            $elements[] = $element;
722        }
723
724        // Add sender fields if they were not merged in the loop above:
725        if ($senderName) {
726            $elements[] = $senderName;
727        }
728        if ($senderEmail) {
729            $elements[] = $senderEmail;
730        }
731
732        if ($this->reportReferrer()) {
733            if ($referrer = ($params['referrer'] ?? false)) {
734                $elements[] = [
735                    'type' => 'hidden',
736                    'name' => 'referrer',
737                    'settings' => ['value' => $referrer],
738                    'label' => 'Referrer',
739                ];
740            }
741        }
742
743        if ($this->reportUserAgent()) {
744            if ($userAgent = ($params['userAgent'] ?? false)) {
745                $elements[] = [
746                    'type' => 'hidden',
747                    'name' => 'useragent',
748                    'settings' => ['value' => $userAgent],
749                    'label' => 'User Agent',
750                ];
751            }
752        }
753
754        $elements[] = [
755            'type' => 'submit',
756            'name' => 'submitButton',
757            'label' => 'Send',
758        ];
759
760        return $elements;
761    }
762
763    /**
764     * Get options for an element
765     *
766     * @param array $element Element configuration
767     *
768     * @return array
769     */
770    protected function getElementOptions(array $element): array
771    {
772        if (!isset($element['options'])) {
773            return [];
774        }
775
776        $options = [];
777        $isSelect = $element['type'] === 'select';
778        $placeholder = $element['placeholder'] ?? null;
779
780        if ($isSelect && $placeholder) {
781            // Add placeholder option (without value) for
782            // select element.
783            $options['o0'] = [
784                'value' => '',
785                'label' => $placeholder,
786                'attributes' => [
787                    'selected' => 'selected', 'disabled' => 'disabled',
788                ],
789            ];
790        }
791        $idx = 0;
792        foreach ($element['options'] as $option) {
793            ++$idx;
794            $value = $option['value'] ?? $option;
795            $label = $option['label'] ?? $option;
796            $options["o$idx"] = [
797                'value' => $value,
798                'label' => $label,
799            ];
800        }
801        return $options;
802    }
803
804    /**
805     * Get option groups for an element
806     *
807     * @param array $element Element configuration
808     *
809     * @return array
810     */
811    protected function getElementOptionGroups(array $element): array
812    {
813        if (!isset($element['optionGroups'])) {
814            return [];
815        }
816        $groups = [];
817        $idx = 0;
818        foreach ($element['optionGroups'] as $group) {
819            if (empty($group['options'])) {
820                continue;
821            }
822            $options = [];
823            foreach ($group['options'] as $option) {
824                ++$idx;
825                $value = $option['value'] ?? $option;
826                $label = $option['label'] ?? $option;
827
828                $options["o$idx"] = [
829                    'value' => $value,
830                    'label' => $label,
831                ];
832            }
833            $groups[$group['label']] = [
834                'label' => $group['label'],
835                'options' => $options,
836            ];
837        }
838        return $groups;
839    }
840
841    /**
842     * Return a list of field names to read from settings file.
843     *
844     * @return array
845     */
846    protected function getFormSettingFields()
847    {
848        return [
849            'emailFrom',
850            'emailSubject',
851            'enabled',
852            'help',
853            'onlyForLoggedUsers',
854            'recipient',
855            'reportReferrer',
856            'reportUserAgent',
857            'response',
858            'senderEmailRequired',
859            'senderInfoRequired',
860            'senderNameRequired',
861            'submit',
862            'title',
863            'useCaptcha',
864            'primaryHandler',
865            'secondaryHandlers',
866            'prefillFields',
867        ];
868    }
869
870    /**
871     * Return a list of field names to read from form element settings.
872     *
873     * @return array
874     */
875    protected function getFormElementSettingFields()
876    {
877        return [
878            'format',
879            'group',
880            'help',
881            'inputType',
882            'maxValue',
883            'minValue',
884            'placeholder',
885            'required',
886            'requireOne',
887            'value',
888        ];
889    }
890
891    /**
892     * Return field names that should not be prefilled.
893     *
894     * @return array
895     */
896    protected function getProtectedFieldNames(): array
897    {
898        return [
899            'referrer',
900            'submit',
901            'userAgent',
902        ];
903    }
904
905    /**
906     * Build form.
907     *
908     * @return void
909     */
910    protected function buildForm()
911    {
912        foreach ($this->formElementConfig as $el) {
913            if ($element = $this->getFormElement($el)) {
914                $this->add($element);
915            }
916        }
917    }
918
919    /**
920     * Get configuration for a Laminas form element
921     *
922     * @param array $el Element configuration
923     *
924     * @return array
925     */
926    protected function getFormElement($el)
927    {
928        $type = $el['type'];
929        if (!($class = $this->getFormElementClass($type))) {
930            return null;
931        }
932
933        $conf = [];
934        $conf['name'] = $el['name'];
935
936        $conf['type'] = $class;
937        $conf['options'] = [];
938
939        $attributes = [
940            'id' => $this->getElementId($el['name']),
941            'class' => [$el['settings']['class'] ?? null],
942        ];
943
944        if ($type !== 'submit') {
945            $attributes['class'][] = 'form-control';
946        }
947
948        if (!empty($el['required'])) {
949            $attributes['required'] = true;
950        }
951        if (!empty($el['settings'])) {
952            $attributes += $el['settings'];
953        }
954        // Add aria-label only if not a hidden field and no aria-label specified:
955        if (
956            !empty($el['label']) && 'hidden' !== $type
957            && !isset($attributes['aria-label'])
958        ) {
959            $attributes['aria-label'] = $el['label'];
960        }
961
962        switch ($type) {
963            case 'checkbox':
964                $options = [];
965                if (isset($el['options'])) {
966                    $options = $el['options'];
967                }
968                $optionElements = [];
969                foreach ($options as $key => $item) {
970                    $optionElements[] = [
971                        'label' => $this->translate($item['label']),
972                        'value' => $key,
973                        'attributes' => [
974                            'id' => $this->getElementId($el['name'] . '_' . $key),
975                        ],
976                    ];
977                }
978                $conf['options'] = ['value_options' => $optionElements];
979                break;
980            case 'date':
981                if (isset($el['minValue'])) {
982                    $attributes['min'] = date('Y-m-d', strtotime($el['minValue']));
983                }
984                if (isset($el['maxValue'])) {
985                    $attributes['max'] = date('Y-m-d', strtotime($el['maxValue']));
986                }
987                break;
988            case 'radio':
989                $options = [];
990                if (isset($el['options'])) {
991                    $options = $el['options'];
992                }
993                $optionElements = [];
994                $first = true;
995                foreach ($options as $key => $option) {
996                    $elemId = $this->getElementId($el['name'] . '_' . $key);
997                    $optionElements[] = [
998                        'label' => $this->translate($option['label']),
999                        'value' => $key,
1000                        'label_attributes' => ['for' => $elemId],
1001                        'attributes' => [
1002                            'id' => $elemId,
1003                        ],
1004                        'selected' => $first,
1005                    ];
1006                    $first = false;
1007                }
1008                $conf['options'] = ['value_options' => $optionElements];
1009                break;
1010            case 'select':
1011                if (isset($el['options'])) {
1012                    $options = $el['options'];
1013                    foreach ($options as $key => &$option) {
1014                        $option['value'] = $key;
1015                    }
1016                    // Unset reference:
1017                    unset($option);
1018                    $conf['options'] = ['value_options' => $options];
1019                } elseif (isset($el['optionGroups'])) {
1020                    $groups = $el['optionGroups'];
1021                    foreach ($groups as &$group) {
1022                        foreach ($group['options'] as $key => &$option) {
1023                            $option['value'] = $key;
1024                        }
1025                        // Unset reference:
1026                        unset($key);
1027                    }
1028                    // Unset reference:
1029                    unset($group);
1030                    $conf['options'] = ['value_options' => $groups];
1031                }
1032                break;
1033            case 'submit':
1034                $attributes['value'] = $el['label'];
1035                $attributes['class'][] = 'btn';
1036                $attributes['class'][] = 'btn-primary';
1037                break;
1038        }
1039
1040        $attributes['class'] = trim(implode(' ', $attributes['class']));
1041        $conf['attributes'] = $attributes;
1042
1043        return $conf;
1044    }
1045
1046    /**
1047     * Get form element class.
1048     *
1049     * @param string $type Element type
1050     *
1051     * @return string|null
1052     */
1053    protected function getFormElementClass($type)
1054    {
1055        $map = [
1056            'checkbox' => '\Laminas\Form\Element\MultiCheckbox',
1057            'date' => '\Laminas\Form\Element\Date',
1058            'email' => '\Laminas\Form\Element\Email',
1059            'hidden' => '\Laminas\Form\Element\Hidden',
1060            'radio' => '\Laminas\Form\Element\Radio',
1061            'select' => '\Laminas\Form\Element\Select',
1062            'submit' => '\Laminas\Form\Element\Submit',
1063            'text' => '\Laminas\Form\Element\Text',
1064            'textarea' => '\Laminas\Form\Element\Textarea',
1065            'url' => '\Laminas\Form\Element\Url',
1066        ];
1067
1068        return $map[$type] ?? null;
1069    }
1070
1071    /**
1072     * Get translated validation message.
1073     *
1074     * @param string $messageId Message identifier
1075     *
1076     * @return string
1077     */
1078    protected function getValidationMessage($messageId)
1079    {
1080        return $this->translate(
1081            $this->messages[$messageId] ?? $messageId
1082        );
1083    }
1084
1085    /**
1086     * Get form elements
1087     *
1088     * @param array $config Form configuration
1089     *
1090     * @return array
1091     */
1092    protected function getFormElements($config)
1093    {
1094        $elements = [];
1095        foreach ($config['fields'] as $field) {
1096            if (!isset($field['type'])) {
1097                continue;
1098            }
1099            $elements[] = $field;
1100        }
1101        return $elements;
1102    }
1103
1104    /**
1105     * Get a complete id for an element
1106     *
1107     * @param string $id Element ID
1108     *
1109     * @return string
1110     */
1111    protected function getElementId(string $id): string
1112    {
1113        return 'form_' . $this->getFormId() . '_' . $id;
1114    }
1115
1116    /**
1117     * Get primary form handler
1118     *
1119     * @return HandlerInterface
1120     */
1121    public function getPrimaryHandler(): HandlerInterface
1122    {
1123        $handlerName = ($this->formConfig['primaryHandler'] ?? 'email');
1124        return $this->handlerManager->get($handlerName);
1125    }
1126
1127    /**
1128     * Get secondary form handlers
1129     *
1130     * @return HandlerInterface[]
1131     */
1132    public function getSecondaryHandlers(): array
1133    {
1134        $handlerNames = (array)($this->formConfig['secondaryHandlers'] ?? []);
1135        return array_map([$this->handlerManager, 'get'], $handlerNames);
1136    }
1137
1138    /**
1139     * Get current form id/name
1140     *
1141     * @return string
1142     */
1143    public function getFormId(): string
1144    {
1145        return $this->formConfig['id'] ?? '';
1146    }
1147
1148    /**
1149     * Validates prefill data and returns only the prefill values for enabled fields
1150     *
1151     * @param array $prefill Prefill data
1152     *
1153     * @return array
1154     */
1155    protected function sanitizePrefill(array $prefill): array
1156    {
1157        $prefillFields = $this->formConfig['prefillFields'] ?? [];
1158        $prefill = array_filter(
1159            $prefill,
1160            function ($key) use ($prefillFields) {
1161                return in_array($key, $prefillFields)
1162                    && !in_array($key, $this->getProtectedFieldNames());
1163            },
1164            ARRAY_FILTER_USE_KEY
1165        );
1166        return $prefill;
1167    }
1168}