praiadeseselle/wire/modules/Inputfield/InputfieldForm.module
2022-03-08 15:55:41 +01:00

618 lines
21 KiB
Text

<?php namespace ProcessWire;
/**
* InputfieldForm: An Inputfield for containing form elements
*
* ProcessWire 3.x, Copyright 2021 by Ryan Cramer
* https://processwire.com
*
* @property string $prependMarkup Optional markup to prepend to the form output
* @property string $appendMarkup Optional markup to append to the form output
* @property bool $protectCSRF Set to false to disable automatic CSRF protection
* @property int $columnWidthSpacing Optionally set the column width spacing (pixels)
* @property string $description Optionally set a description headline for the form
* @property string $confirmText Confirmation text that precedes list of changes when class InputfieldFormConfirm is active
* @property string $method Form method attribute (default="post")
* @property string $action Form action attribute (default="./")
*
* @method void renderOrProcessReady($type) Hook called before form render or process (3.0.171+)
*
* Optional classes:
* =================
* InputfieldFormNoHeights: tells it not to worry about lining up all columns vertically.
* InputfieldFormNoWidths: indicates that form will be in 2-column label => input format (column widths do not apply)
* InputfieldFormConfirm: tell it to notify user if they make any changes and forgot to submit.
*
* #pw-body =
* Here is an example of creating an InputfieldForm using Inputfield modules. This particular example
* is an email subscription form.
* ~~~~~
* $form = $modules->get('InputfieldForm');
*
* $field = $modules->get('InputfieldText');
* $field->attr('name', 'your_name');
* $field->label = 'Your Name';
* $form->add($field);
*
* $field = $modules->get('InputfieldEmail');
* $field->attr('name', 'your_email');
* $field->label = 'Your Email Address';
* $field->required = true;
* $form->add($field);
*
* $submit = $modules->get('InputfieldSubmit');
* $submit->attr('name', 'submit_subscribe');
* $form->add($submit);
*
* if($input->post('submit_subscribe')) {
* // form submitted
* $form->processInput($input->post);
* $errors = $form->getErrors();
* if(count($errors)) {
* // unsuccessful submit, re-display form
* echo "<h3>There were errors, please fix</h3>";
* echo $form->render();
* } else {
* // successful submit (save $name and $email somewhere)
* $name = $form->get('your_name')->attr('value');
* $email = $form->get('your_email')->attr('value');
* echo "<h3>Thank you, you have been subscribed!</h3>";
* }
* } else {
* // form not submitted, just display it
* echo $form->render();
* }
* ~~~~~
*
* #pw-body
*
*/
class InputfieldForm extends InputfieldWrapper {
public static function getModuleInfo() {
return array(
'title' => __('Form', __FILE__), // Module Title
'summary' => __('Contains one or more fields in a form', __FILE__), // Module Summary
'version' => 107,
'permanent' => true,
);
}
const debug = false; // set to true to enable debug mode for field dependencies
/**
* WireInputData provided to processInput() method
*
* @var WireInputData|null
*
*/
protected $_processInputData = null;
/**
* Construct
*
*/
public function __construct() {
$this->set('protectCSRF', true);
parent::__construct();
$this->attr('method', 'post');
$this->attr('action', './');
$this->set('class', '');
$this->set('prependMarkup', '');
$this->set('appendMarkup', '');
$this->set('confirmText', $this->_('There are unsaved changes:'));
}
/**
* Render form
*
* @return string
*
*/
public function ___render() {
if($this->hasHook('renderOrProcessReady()')) $this->renderOrProcessReady('render');
$markup = self::getMarkup();
$classes = self::getClasses();
if(!empty($classes['form'])) $this->addClass($classes['form']);
$this->attr('data-colspacing', (int) $this->getSetting('columnWidthSpacing'));
$this->addClass('InputfieldForm');
if($this->hasClass('InputfieldFormConfirm')) {
if($this->wire('modules')->isInstalled('FormSaveReminder')) {
// let FormSaveReminder module have control, if it's installed
$this->removeClass('InputfieldFormConfirm');
} else {
$this->attr('data-confirm', $this->getSetting('confirmText'));
}
}
$attrs = $this->getAttributes();
unset($attrs['value']);
if($this->wire('input')->get('modal') && strpos($attrs['action'], 'modal=1') === false) {
// retain a modal=1 state in the form action
$attrs['action'] .= (strpos($attrs['action'], '?') === false ? '?' : '&') . 'modal=1';
}
$description = $this->getSetting('description');
if($description) $description = str_replace('{out}', $this->entityEncode($description), $markup['item_head']);
$attrStr = $this->getAttributesString($attrs);
if($this->getSetting('protectCSRF') && strtolower($this->attr('method')) == 'post') {
$tokenField = $this->wire('session')->CSRF->renderInput();
} else {
$tokenField = '';
}
/* @todo 3.0.125
$name = $this->getAttribute('name');
if(!empty($name)) {
$name = $this->wire('sanitizer')->entities($name);
$class = $this->className();
$tokenField .= "<input type='hidden' name='_$class' value='$name' />";
}
*/
$out =
"<form $attrStr>" .
$description . $this->getSetting('prependMarkup') .
parent::___render() .
$tokenField .
$this->getSetting('appendMarkup') .
"</form>";
return $out;
}
/**
* Process input
*
* @param WireInputData $input
* @return InputfieldWrapper
*
*/
public function ___processInput(WireInputData $input) {
$this->_processInputData = $input;
if($this->hasHook('renderOrProcessReady()')) $this->renderOrProcessReady('process');
$this->getErrors(true); // reset
if($this->getSetting('protectCSRF') && strtolower($this->attr('method')) === 'post') {
$this->wire()->session->CSRF->validate(); // throws exception if invalid
}
$result = parent::___processInput($input);
$delayedChildren = $this->_getDelayedChildren(true);
$delayedChildren = $this->processInputShowIf($input, $delayedChildren);
$this->processInputRequiredIf($input, $delayedChildren);
return $result;
}
/**
* Process input for show-if dependencies
*
* @param WireInputData $input
* @param array $delayedChildren
* @return array
*
*/
protected function processInputShowIf(WireInputData $input, array $delayedChildren) {
if(!count($delayedChildren)) return $delayedChildren;
$maxN = 255;
$n = 0;
$delayedN = count($delayedChildren);
$processedN = 0;
$unprocessedN = 0;
/** @var Inputfield[] $savedChildren */
$savedChildren = $delayedChildren;
while(count($delayedChildren)) {
if(++$n >= $maxN) {
$this->error("Max number of iterations reached for processing field dependencies", Notice::debug);
break;
}
// shift first $child off the array
$child = array_shift($delayedChildren);
if(self::debug) $this->debugNote("Processing delayed child: $child->name ($child->label)");
$selectorString = $child->getSetting('showIf');
if(!strlen($selectorString)) {
if(self::debug) $this->debugNote("Skipping $child->name ($child->label): No selector string");
continue;
}
if(self::debug) $this->debugNote("showIf selector: $selectorString");
$selectors = $this->wire(new Selectors($selectorString));
// whether we should process $child now or not
$processNow = true;
$selector = null;
foreach($selectors as $selector) {
$fields = is_array($selector->field) ? $selector->field : array($selector->field);
// first determine that the dependency fields have already been processed
foreach($fields as $name) {
if(self::debug) $this->debugNote("$child->name requires: $name");
if(isset($savedChildren[$name]) && $name !== "1") {
// if field had already been through the loop, but was not processed, add it back in for processing
if(!isset($delayedChildren[$name])
&& !$savedChildren[$name]->getSetting('showIfProcessed')
&& !$savedChildren[$name]->getSetting('showIfSkipped')) {
$delayedChildren[$name] = $savedChildren[$name];
}
// force $delayedChildren[$name] to match so that it is processed here, by giving it special selector: 1>0
if(!strlen($savedChildren[$name]->getSetting('showIf'))) {
$savedChildren[$name]->showIf = '1>0'; // forced match
}
if($savedChildren[$name]->getSetting('showIfSkipped')) {
// dependency $field does not need to be processed, so neither does this field
list($val, $op) = array($selector->value, $selector->operator);
if(($op === '!=' && $val) || $op === '=' && empty($val)) {
// allow it through to match non-presence (via issue #890)
} else {
unset($delayedChildren[$child->name]);
$processNow = false;
if(self::debug) $this->debugNote("Removing field '$child->name' because '$name' it not shown.");
}
} else if(!$savedChildren[$name]->getSetting('showIfProcessed')) {
// dependency $field is another one in $delayedChildren, send it back to the end
unset($delayedChildren[$child->name]);
// put it back on the end
$delayedChildren[$child->name] = $child;
if(self::debug) $this->debugNote("Sending field '$child->name' back to the end.");
$processNow = false;
}
break;
} else {
// $field is most likely a form field that has already been processed and is good to use
$processNow = true;
}
} // foreach($fields)
if(!$processNow) break; // out to next $child
$numFieldsMatched = 0;
// good to process $child
foreach($fields as $name) {
if($name == '1') {
$numFieldsMatched++;
} else if($this->selectorMatchesInputfield($selector, $name, "'showIf' from '$child->label'")) {
$numFieldsMatched++;
}
} // $fields
$processNow = $numFieldsMatched > 0;
// @todo 3.0.150: if($processNow) $child->set('showIfSkipped', false); // https://processwire.com/talk/topic/22130-forced-10-inputfield-dependency-issues/
if(!$processNow) break;
if(self::debug) $this->debugNote("$child->name ($child->label) - matched: showIf($selector)");
$processedN++;
} // $selectors
if(!$processNow) {
if(self::debug) {
$this->debugNote("$child->name ($child->label) - did not match: showIf($selector)");
$this->debugNote("Skipped processing for: $child->name ($child->label)");
}
$child->set('showIfSkipped', true); // flag the field as skipped
$unprocessedN++;
// since this didn't match, then no other selectors in the group for this child can match, so break out of the selector loop
continue; // to next $child
}
// the required dependency is in place so that $child can be processed
// temporarily remove the showIf property to prevent InputfieldWrapper's from delaying it again
$showIf = $child->getSetting('showIf');
$child->set('showIf', ''); // remove showIf property
$child->processInput($input); // process input
if($showIf != '1>0') $child->set('showIf', $showIf); // restore showIf property
$child->set('showIfProcessed', true); // flag it as processed
if(self::debug) $this->debugNote("$child->name - processed!");
// now check if the processed child has children of it's own that may have been delayed
if($child instanceof InputfieldWrapper) {
$delayed = $child->_getDelayedChildren(true);
if(count($delayed)) {
foreach($delayed as $d) {
$dname = $d->attr('name');
if(!$dname) $dname = $d->attr('id');
if(self::debug) $this->debugNote("Delayed: $dname ($d->label)");
}
$delayedChildren = array_merge($delayedChildren, $delayed); // add them to delayed children
$savedChildren = array_merge($savedChildren, $delayed); // add them to saved children (to be sent to requiredIf too)
$delayedN += count($delayed);
}
}
} // count($delayedChildren)
if(self::debug) $this->debugNote("delayedChildren: $delayedN ($processedN processed, $unprocessedN not)");
return $savedChildren;
}
/**
* Process input for fields with a required-if dependency
*
* @param WireInputData $input
* @param array|Inputfield[] $delayedChildren
*
*/
protected function processInputRequiredIf(WireInputData $input, array $delayedChildren) {
// process input for any remaining delayedChildren not already processed by processInputShowIf
foreach($delayedChildren as $child) {
if($child->getSetting('showIfSkipped') || $child->getSetting('showIfProcessed')) continue;
if(self::debug) $this->debugNote("Now Processing requiredIf delayed child: $child->name");
$child->processInput($input);
}
while(count($delayedChildren)) {
// shift first $child off the array
$child = array_shift($delayedChildren);
if(!$child->getSetting('required') || $child->getSetting('requiredSkipped')) continue;
// if field was not shown, then it can't be required
if($child->getSetting('showIfSkipped') && !$child->getSetting('showIfProcessed')) {
$child->set('requiredSkipped', true);
continue;
}
$required = true;
$selectorString = $child->getSetting('requiredIf');
if(strlen($selectorString)) {
if(self::debug) $this->debugNote("requiredIf selector: $selectorString");
$selectors = $this->wire(new Selectors($selectorString));
foreach($selectors as $selector) {
$fields = is_array($selector->field) ? $selector->field : array($selector->field);
foreach($fields as $name) {
$matches = $this->selectorMatchesInputfield($selector, $name, 'requiredIf');
if($matches === null) continue;
if($matches === false) {
$required = false;
break;
}
} // foreach($fields)
if(!$required) break;
} // foreach($selectors)
} // if(strlen($selectorString))
if($required) {
if($child->isEmpty()) {
if(self::debug) $this->debugNote("$child->name - determined that value IS required and is not present (error)");
$requiredLabel = $child->getSetting('requiredLabel');
if(empty($requiredLabel)) $requiredLabel = $this->requiredLabel; // requiredLabel from InputfieldWrapper
$child->error($requiredLabel);
} else {
if(self::debug) $this->debugNote("$child->name - determined that value IS required and is populated (good)");
}
} else {
if(self::debug) $this->debugNote("$child->name - determined that value is not required");
$child->set('requiredSkipped', true);
}
}
}
/**
* Does the selector match the given Inputfield name?
*
* @param Selector $selector
* @param string $name Name of Inputfield
* @param string $debugNote Optional qualifier note for debugging
* @return bool|null Returns true|false if match determined, or NULL if $name is not present in form
*
*/
protected function selectorMatchesInputfield(Selector $selector, $name, $debugNote = '') {
$subfield = '';
if(strpos($name, '.')) list($name, $subfield) = explode('.', $name);
// get the inputfield that $child has a dependency on
$inputfield = $this->getChildByName($name);
// if field is not present in this form, we assume a blank value for it
if(!$inputfield) {
if($name != 'collapsed') {
$this->error("Warning ($debugNote): dependency field '$name' is not present in this form.", Notice::debug);
}
return null;
}
$value = $inputfield->attr('value');
$value2 = null;
$matches = false;
if($subfield == 'count') {
$value = wireCount($value);
if(self::debug) $this->debugNote("Actual count ($debugNote): $value");
}
if(is_object($value)) $value = "$value";
if($inputfield instanceof InputfieldSelect && $subfield != 'count') {
$allowCheckLabels = false; // allow for match by field labels, in addition to field values
$options = $inputfield->getOptions();
// determine if selector values are referring to a value or a label
foreach($selector->values as $selectorValue) {
// if value in selector matches a known option 'label' then we allow use of labels
$key = array_search($selectorValue, $options);
if(self::debug) $this->debugNote("OPTIONS ($debugNote): Searching for label '$selectorValue' in " . print_r($options, true));
if($key !== false) {
$allowCheckLabels = true;
if(self::debug) $this->debugNote("OPTIONS ($debugNote): Found '$selectorValue' so allowing label check");
break;
} else {
if(self::debug) $this->debugNote("OPTIONS ($debugNote): Did not find '$selectorValue'");
}
}
if($allowCheckLabels) {
if($inputfield instanceof InputfieldHasArrayValue) {
$value2 = array(); // matching of labels rather than values
foreach($value as $k => $v) {
if(isset($options[$v])) $value2[] = $options[$v];
}
if(empty($value2)) $value2 = null;
} else {
if(isset($options[$value])) $value2 = $options[$value];
}
}
}
if($selector->matches($value)) {
if(self::debug) $this->debugNote("Selector ($debugNote) matched value \"$selector\" (field=$name, value=" . print_r($value, true) . ")");
$matches = true;
} else if($value2 !== null && $selector->matches($value2)) {
if(self::debug) {
$this->debugNote("Selector ($debugNote) did NOT match value \"$selector\" (field=$name, value=" . print_r($value, true) . ")");
$this->debugNote("Selector ($debugNote) matched label \"$selector\" (field=$name, label=" . print_r($value2, true) . ")");
}
$matches = true;
} else {
if(self::debug) $this->debugNote("Selector ($debugNote) failed to match \"$selector\" (field=$name, value=" . print_r($value, true) . ")");
}
return $matches;
}
/**
* Is this form submitted?
*
* This should only be called after the form has been completely built and submit buttons added to it.
* When using this option it is preferable (though not required) that your form has been given a name attribute.
* Optionally provide the name (or instance) of the submit button you want to check.
*
* - Returns boolean false if not submitted.
* - Returns boolean true if submitted, but submit button not known or not used.
* - Returns clicked/submitted InputfieldSubmit object instance when known and available.
*
* @param string|InputfieldSubmit $submitName Name of submit button or instance of InputfieldSubmit
* @return bool|InputfieldSubmit
* @todo 3.0.125
*
public function isSubmitted($submitName = '') {
$method = strtoupper($this->attr('method'));
$input = $this->wire('input');
$submit = false;
$formName = $this->getAttribute('name');
$submitNames = array();
// if the current request method is not the same as the forms, exit early
if(!$input->requestMethod($method)) return false;
if(!empty($submitName)) {
// given a specific submit button to check
if($submitName instanceof InputfieldSubmit) {
$submitName = $submitName->attr('name');
}
if(is_string($submitName)) {
$submitNames[] = $submitName;
}
}
if(empty($submitNames)) {
// no specific submit button, so check all known submit button names
$submitNames = InputfieldSubmit::getSubmitNames();
}
if(!empty($formName)) {
// this form has a name attribute, so we can add that to our checks
$key = '_' . $this->className();
$value = $method === 'GET' ? $input->get($key) : $input->post($key);
if($value !== $formName) {
// submitted form name does not match this form
return false;
}
// at this point we know this form as submitted
$submit = true;
}
// find out which submit button was clicked if possible
foreach($submitNames as $name) {
$value = $method === 'GET' ? $input->get($name) : $input->post($name);
// if value not present in input for this submit button then skip it
if($value === null) continue;
// value was submitted for button name
$f = $this->getChildByName($name);
// if value submitted is not the same as value on the button, skip it
if(!$f || $f->val() !== $value) continue;
// we found our submit button
$submit = $f;
break;
}
// if submitted and CSRF protection in place, check that token is valid
if($submit && $this->getSetting('protectCSRF') && $method === 'POST') {
$csrf = $this->wire('session')->CSRF();
if(!$csrf->hasValidToken()) $submit = false;
}
return $submit ? $submit : false;
}
*/
/**
* For internal debugging purposes
*
* @param $note
*
*/
protected function debugNote($note) {
static $n = 0;
if(self::debug) $this->message((++$n) . ". $note");
}
/**
* Return WireInputData provided to processInput() method or null if not yet applicable
*
* @return WireInputData|null
* @since 3.0.171
*
*/
public function getInput() {
return $this->_processInputData;
}
/**
* Hook called right before form is rendered or processed
*
* @param string $type One of 'render' or 'process'
* @since 3.0.171
*
*/
protected function ___renderOrProcessReady($type) {
}
}