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'); * * $f = $form->InputfieldText; * $f->attr('name', 'your_name'); * $f->label = 'Your Name'; * $form->add($f); * * $f = $form->InputfieldEmail; * $f->attr('name', 'your_email'); * $f->label = 'Your Email Address'; * $f->required = true; * $form->add($f); * * $f = $form->InputfieldSubmit; * $f->attr('name', 'submit_subscribe'); * $f->val('Subscribe'); * $form->add($f); * * // ProcessWire versions 3.0.205+ * if($form->isSubmitted('submit_subscribe')) { * if($form->process()) { * $name = $form->getValueByName('your_name'); * $email = $form->getValueByName('your_email'); * echo "

Thank you, you have been subscribed!

"; * } else { * echo "

There were errors, please fix

"; * echo $form->render(); * } * } else { * // form not submitted, just display it * echo $form->render(); * } * * // same as above but works in any ProcessWire version * if($input->post('submit_subscribe')) { * // form submitted * $form->processInput($input->post); * $errors = $form->getErrors(); * if(count($errors)) { * // unsuccessful submit, re-display form * echo "

There were errors, please fix

"; * echo $form->render(); * } else { * // successful submit (save $name and $email somewhere) * $name = $form->getChildByName('your_name')->attr('value'); * $email = $form->getChildByName('your_email')->attr('value'); * echo "

Thank you, you have been subscribed!

"; * } * } 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; /** * @var array|null */ protected $errorCache = 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'); $method = strtolower($this->attr('method')); $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') && $method === 'post') { $tokenField = $this->wire()->session->CSRF->renderInput(); } else { $tokenField = ''; } if($method === 'post') { $className = $this->className(); $formName = $this->wire()->sanitizer->entities($this->getFormName()); $landmark = ""; } else { $landmark = ''; } return "
" . $description . $this->getSetting('prependMarkup') . parent::___render() . $tokenField . $this->getSetting('appendMarkup') . $landmark . "
"; } /** * Process the form * * - Optionally use this rather than processInput() for processing forms. * - Returns true on processing success or false if processed with errors. * - Use the getErrors() method to retrieve errors. * * ~~~~~ * if($form->process()) { * // form processed successfully without errors * } else { * $errors = $form->getErrors(); // array of error messages… * $inputs = $form->getErrorInputfields(); // …or array of Inputfields with errors * } * ~~~~~ * * @return bool * @throws WireException * @since 3.0.205 * */ public function ___process() { $input = $this->wire()->input; $method = strtolower($this->attr('method')); $this->processInput(($method === 'get' ? $input->get : $input->post)); return count($this->getErrors()) === 0; } /** * Process input * * @param WireInputData $input * @return InputfieldWrapper * */ public function ___processInput(WireInputData $input) { $this->errorCache = null; $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"); /** @var Selectors $selectors */ $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"); /** @var Selectors $selectors */ $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 $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 form submitted and ready to process? * * - Optionally use this method to test if form is submitted before calling processInput(). * - Specify the submit button name to confirm that was the button used to submit. * - Returns the name of the submit button used when form is submitted, false otherwise. * - If no arguments specified, requires that the form has one or more InputfieldSubmit fields. * - Like with processInput(), make sure form is fully built before calling this. * - If given $submitName argument that corresponds to InputfieldSubmit field then * it will also be confirmed that the input value matches the field value prior to submit. * - If given a $submitName argument that corresponds to some other Inputfield type then * only its presence in the input will be confirmed. * * ~~~~~ * if($form->isSubmitted()) { * // form was submitted * } * * // specify the button name to confirm it was used to submit the form * if($form->isSubmitted('submit_save')) { * // form is submitted with button named 'submit_save' * } * * // omit button name to have it return button name used to submit * $submit = $form->isSubmitted(true); * if($submit === 'add') { * // form was submitted with button named 'add' * } else if($submit === 'save') { * // form submitted with button named 'save' * } else if($submit === false) { * // form not submitted * } else { * // submitted using some other button (name in $submit) * } * ~~~~~ * * @param string|Inputfield|bool $submitName Any one of the following: * - Name (string) or Inputfield (object) of submit button or other input to check. * - Boolean true to find and return the clicked submit button name. * - Boolean false or omit to only return true or false if form submitted (default). * @return bool|string Returns one of the following: * - Boolean false if form not submitted. * - Boolean true if form submitted and submit button name not requested. * - Submit/input name (string) if form submitted and `$submitName` argument is true or string. * @throws WireException * @since 3.0.205 * */ public function isSubmitted($submitName = '') { $session = $this->wire()->session; $input = $this->wire()->input; $className = $this->className(); $formName = $this->getFormName(); $method = $this->attr('method') ? strtolower($this->attr('method')) : 'post'; $checkToken = $this->getSetting('protectCSRF') && $method != 'get'; if(!$input->requestMethod($method)) return false; if($method === 'post' && $input->$method("_$className") !== $formName) return false; if($checkToken && !$session->CSRF()->hasValidToken()) return false; if(empty($submitName)) { // no submit button name requested $submitted = true; } else if($submitName === true) { // find which submit button was clicked to return its name $submitted = false; foreach($this->getAll() as $f) { if(!$f instanceof InputfieldSubmit) continue; $name = $f->attr('name'); $submitted = $input->$method($name) === $f->val() ? $name : false; if($submitted) break; } } else { // confirm that requested submit button was clicked and has same value if($submitName instanceof Inputfield) $submitName = $submitName->attr('name'); $value = $input->$method($submitName); if($value === null) return false; $f = $this->getChildByName($submitName); if($f instanceof InputfieldSubmit && $f->val() != $value) return false; $submitted = $submitName; } return $submitted; } /** * 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; } /** * Return an array of errors that occurred on any of the children during input processing. * * Should only be called after `process()` or `processInput()`. * * #pw-group-errors * * @param bool|null $clear Clear or re-check for errors? (default=false) * - Specify `true` to clear out the errors. * - Specify `false` or omit the argument to neither clear or uncache errors. (default) * - Specify `null` to uncache a previous call and re-check all fields in the form. (3.0.223+) * @return array Array of error strings * */ public function getErrors($clear = false) { if($clear === null) { $this->errorCache = null; $clear = false; } if($this->errorCache !== null && $clear === false) return $this->errorCache; $errors = parent::getErrors($clear); $this->errorCache = $clear ? null : $errors; return $errors; } /** * Get name for this form * * @return string * @since 3.0.205 * */ public function getFormName() { $formName = $this->attr('name'); if(empty($formName)) $formName = $this->attr('id'); if(empty($formName)) $formName = $this->className(); return (string) $formName; } /** * 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) { } }