JezK
Edit File: Form.php
<?php namespace GeminiLabs\SiteReviews\Modules\Html; use GeminiLabs\SiteReviews\Arguments; use GeminiLabs\SiteReviews\Contracts\FieldContract; use GeminiLabs\SiteReviews\Contracts\FormContract; use GeminiLabs\SiteReviews\Contracts\PluginContract; use GeminiLabs\SiteReviews\Helpers\Arr; use GeminiLabs\SiteReviews\Helpers\Cast; use GeminiLabs\SiteReviews\Helpers\Str; use GeminiLabs\SiteReviews\Modules\Captcha; use GeminiLabs\SiteReviews\Modules\Encryption; use GeminiLabs\SiteReviews\Modules\Honeypot; use GeminiLabs\SiteReviews\Modules\Sanitizer; use GeminiLabs\SiteReviews\Modules\Style; class Form extends \ArrayObject implements FormContract { public Arguments $args; public array $config; protected Arguments $session; /** * The $args are expected to be sanitized before being passed to the form. */ public function __construct(array $args = [], array $values = [], array $config = []) { if (empty($args['id'])) { $args['id'] = glsr(Sanitizer::class)->sanitizeIdUnique(''); } $args = wp_parse_args($args, [ 'button_text' => __('Submit Form', 'site-reviews'), 'button_text_loading' => __('Submitting, please wait...', 'site-reviews'), ]); $this->args = glsr()->args($args); $this->loadSession($values); // before mergeConfig(), which may read the session $this->config = $this->mergeConfig($config); parent::__construct($this->fieldsAll(), \ArrayObject::STD_PROP_LIST | \ArrayObject::ARRAY_AS_PROPS); array_map([$this, 'normalizeConditions'], $this->fields()); $this->app()->action("{$this->formName()}/form", $this); $this->signForm(); } public function app(): PluginContract { return glsr(); } public function args(): Arguments { return $this->args; } public function build(): string { return glsr(Template::class)->build('templates/reviews-form', [ 'args' => $this->args, 'context' => [ 'class' => $this->classAttrForm(), 'fields' => $this->buildFields(), 'response' => $this->buildResponse(), 'submit_button' => $this->buildSubmitButton(), ], 'form' => $this, ]); } public function config(): array { return []; } public function configHidden(): array { return []; } public function field(string $name, array $args): FieldContract { $className = $this->fieldClass(); $field = new $className(wp_parse_args($args, compact('name'))); $this->normalizeField($field); return $field; } public function fieldClass(): string { return Field::class; } /** * @return FieldContract[] */ public function fields(): array { return $this->getArrayCopy(); } /** * @return FieldContract[] */ public function fieldsFor(string $group): array { return array_filter($this->fields(), fn ($field) => $group === $field->group); } public function formName(): string { $formName = (new \ReflectionClass($this))->getShortName(); $formName = Str::dashCase($formName); return $formName; } /** * @return FieldContract[] */ public function hidden(): array { $fields = []; foreach ($this->fields() as $field) { if ('hidden' === $field->original_type) { $fields[] = $field; } } usort($fields, fn ($a, $b) => $a->original_name <=> $b->original_name); return $fields; } public function loadSession(array $values): void { $this->session = glsr()->args([ 'errors' => [], 'failed' => false, 'message' => '', 'success' => false, 'values' => $values, ]); } #[\ReturnTypeWillChange] public function offsetGet($key) { $iterator = $this->getIterator(); if (is_numeric($key)) { return $iterator[$key] ?? null; } foreach ($iterator as $field) { if ($key === $field->original_name) { return $field; } } return null; } public function session(): Arguments { return $this->session; } /** * @return FieldContract[] */ public function visible(): array { $fields = []; foreach ($this->fields() as $field) { if ('hidden' !== $field->original_type) { $fields[] = $field; } } return $fields; } /** * An array of field names that can be overridden in the hiddenConfig array * by fields with the same name in the config array. * * @return string[] */ protected function allowedHiddenFieldOverrides(): array { return []; } /** * The container a run of same-group fields renders inside. The group * travels as a data attribute rather than a class: it is the form's * own value (a page number, a step name) and has no business in the * style pack's vocabulary, while the class is the pack's to set. * * The args are filtered rather than the rendered markup, so what is * added lands inside the fieldset — a legend for the group, or * anything else that belongs to it — without anyone having to cut * open a string. */ protected function buildFieldset(string $group, string $fields): string { $args = [ 'class' => glsr(Style::class)->classes('fieldset'), 'data-group' => $group, 'text' => $fields, ]; $args = $this->app()->filterArray("{$this->formName()}/build/fieldset", $args, $group, $this); return glsr(Builder::class)->fieldset($args); } protected function buildFields(): string { $fields = []; foreach ($this->hidden() as $field) { $fields[] = $field->build(); } $fields[] = glsr(Honeypot::class)->build($this->args->id); foreach ($this->groupedFields() as $run) { // A run with no group is the ungrouped rendering: no wrapper, // exactly what every form produced before groups existed. $fields[] = '' === $run['group'] ? $run['fields'] : $this->buildFieldset($run['group'], $run['fields']); } $rendered = implode("\n", $fields); $rendered = $this->app()->filterString("{$this->formName()}/build/fields", $rendered, $this); return $rendered; } protected function buildResponse(): string { $rendered = glsr(Template::class)->build('templates/form/response', [ 'context' => [ 'class' => $this->classAttrResponse(), 'message' => wpautop($this->session->message), ], 'has_errors' => !empty($this->session->errors), ]); $rendered = $this->app()->filterString("{$this->formName()}/build/response", $rendered, $this); return $rendered; } protected function buildSubmitButton(): string { $captcha = glsr(Captcha::class)->container(); $rendered = glsr(Template::class)->build('templates/form/submit-button', [ 'context' => [ 'class' => $this->classAttrSubmitButton(), 'loading_text' => $this->args->button_text_loading, 'text' => $this->args->button_text, ], ]); $rendered = $this->app()->filterString("{$this->formName()}/build/submit_button", $rendered, $this); if ('above' === glsr(Captcha::class)->position()) { $rendered = $captcha.$rendered; } else { $rendered = $rendered.$captcha; } return $rendered; } protected function classAttrForm(): string { $classes = [ $this->args->class, glsr(Style::class)->classes('form'), ]; if (!empty($this->session->errors)) { $classes[] = glsr(Style::class)->validation('form_error'); } if (glsr_get_option('settings.forms.session_storage', false, 'bool')) { $classes[] = 'glsr-persist-data'; } $classes = implode(' ', $classes); $classes = glsr(Sanitizer::class)->sanitizeAttrClass($classes); return $classes; } protected function classAttrResponse(): string { $classes = [ glsr(Style::class)->validation('form_message'), ]; if (!empty($this->session->errors)) { $classes[] = glsr(Style::class)->validation('form_message_failed'); } if ($this->session->success) { $classes[] = glsr(Style::class)->validation('form_message_success'); } $classes = implode(' ', $classes); $classes = glsr(Sanitizer::class)->sanitizeAttrClass($classes); return $classes; } protected function classAttrSubmitButton(): string { $classes = glsr(Style::class)->classes('button'); $classes = glsr(Sanitizer::class)->sanitizeAttrClass($classes); return $classes; } /** * @return FieldContract[] */ protected function fieldsAll(): array { $fields = array_values(array_merge($this->fieldsHidden(), $this->fieldsVisible())); $fields = $this->app()->filterArray("{$this->formName()}/fields/all", $fields, $this); return $fields; } /** * @return FieldContract[] */ protected function fieldsHidden(): array { $fields = []; foreach ($this->config as $name => $args) { if ('hidden' !== $args['type']) { continue; } $field = $this->field($name, $args); if ($field->isValid()) { $fields[$name] = $field; } } $fields = $this->app()->filterArray("{$this->formName()}/fields/hidden", $fields, $this); return $fields; } /** * @return FieldContract[] */ protected function fieldsVisible(): array { $fields = []; foreach ($this->config as $name => $args) { if ('hidden' === $args['type']) { continue; } $field = $this->field($name, $args); if ($field->isValid()) { $fields[$name] = $field; } } $fields = $this->app()->filterArray("{$this->formName()}/fields/visible", $fields, $this); return $fields; } /** * The visible fields as rendered runs of the group they share. Runs * follow the rendered order rather than the config's, and fields with * no group make runs of their own, so a form that groups nothing * renders exactly what it rendered before: one run, no wrapper. * * A list rather than a map keyed by group, because a group can be * interrupted by another and resume later — that is the honest * rendering of the field order the author gave, and a map would * silently drop the earlier run. * * @return array<array{group: string, fields: string}> */ protected function groupedFields(): array { $runs = []; $group = null; foreach ($this->visible() as $field) { $fieldGroup = Cast::toString($field->group); if ($fieldGroup !== $group) { $runs[] = ['group' => $fieldGroup, 'fields' => []]; $group = $fieldGroup; } $runs[array_key_last($runs)]['fields'][] = $field->build(); } return array_map(fn ($run) => [ 'group' => $run['group'], 'fields' => implode("\n", $run['fields']), ], $runs); } protected function mergeConfig(array $config): array { $config = array_merge($config, $this->config()); $config = $this->app()->filterArray("{$this->formName()}/config", $config, $this); if (!wp_is_numeric_array($config)) { // allow custom filtered field order $order = array_keys($config); $order = $this->app()->filterArray("{$this->formName()}/fields/order", $order, $this); $ordered = array_intersect_key(array_merge(array_flip($order), $config), $config); $config = $ordered; } array_walk($config, function (&$field, $key) { if (!is_numeric($key)) { // ensure all fields include a name value to allow the field check below. $field['name'] = $key; } }); foreach ($this->configHidden() as $name => $value) { if (in_array($name, $this->allowedHiddenFieldOverrides())) { $search = Arr::searchByKey($name, $config, 'name'); if (false !== $search) { continue; // skip this hidden field } } $config[$name] = [ 'name' => $name, 'type' => 'hidden', 'value' => $value, ]; } $config = array_filter($config, fn ($args) => !empty($args['type'])); return $config; } /** * @todo Should this be done manually? This shouldn't be run when getting field rules for validation */ protected function normalizeConditions(FieldContract $field): void { if (!$conditions = $field->conditions()) { return; } $results = []; foreach ($conditions['conditions'] as $args) { if (!$triggerField = $this->offsetGet($args['name'])) { $results[] = true; // ignore if the condition's field does not exist continue; } $results[] = (new FieldCondition($args, $triggerField))->isValid(); } $results = array_filter($results); if (count($results) === count($conditions['conditions'])) { return; // all conditions valid } if ('any' === $conditions['criteria'] && !empty($results)) { return; // some conditions valid } $field->is_hidden = true; // reset value } /** * Normalize the field with the form's session data. * Any normalization that is not specific to the form or session data * should be done in the field itself. */ protected function normalizeField(FieldContract $field): void { $this->normalizeFieldChecked($field); $this->normalizeFieldErrors($field); $this->normalizeFieldId($field); $this->normalizeFieldValue($field); } /** * Set the checked attribute of the field from the session. */ protected function normalizeFieldChecked(FieldContract $field): void { if (!$field->isChoiceField()) { return; } if (!is_scalar($field->value)) { return; } $value = Cast::toString($this->session->values[$field->original_name] ?? ''); if (empty($value)) { return; } $field->checked = (string) $field->value === $value; } /** * Set the field errors from the session. */ protected function normalizeFieldErrors(FieldContract $field): void { $errors = $this->session->errors[$field->original_name] ?? []; $field->errors = Arr::consolidate($errors); } /** * Prefix the field id with the form id. */ protected function normalizeFieldId(FieldContract $field): void { if (empty($this->args->id)) { return; } if (empty($field->id)) { return; } if ($field->is_raw) { return; } $fieldId = Str::removePrefix($field->id, $field->namePrefix()); $fieldId = Str::prefix($fieldId, $this->args->id); $field->id = $fieldId; } /** * Set the value attribute of the field from the session. */ protected function normalizeFieldValue(FieldContract $field): void { if ($field->isChoiceField()) { $value = Cast::toArray($this->session->values[$field->original_name] ?? []); } else { $value = Cast::toString($this->session->values[$field->original_name] ?? ''); } if (empty($value)) { return; } $field->value = $value; } protected function signForm(): void { $hidden = $this->hidden(); $values = []; foreach ($hidden as $field) { $values[$field->original_name] = $field->value; } $values = $this->app()->filterArray("{$this->formName()}/signature/values", $values, $this); $signatureField = $this->field('form_signature', [ 'type' => 'hidden', 'value' => glsr(Encryption::class)->encrypt(maybe_serialize($values)), ]); $fields = array_values(array_merge($hidden, [$signatureField], $this->visible())); $this->exchangeArray($fields); } }