src/HTMLForm/Form.php
<?php
namespace Anax\HTMLForm;
use Psr\Container\ContainerInterface;
/**
* A utility class to easy creating and handling of forms
*/
class Form implements \ArrayAccess
{
/**
* @var array $form settings for the form
* @var array $elements all form elements
* @var array $output messages to display together with the form
* @var array $sessionKey key values for the session
*/
protected $form;
protected $elements;
protected $output;
protected $sessionKey;
/**
* @var boolean $rememberValues remember values in the session.
*/
protected $rememberValues;
/**
* @var Anax\DI\DIInterface $di the DI service container.
*/
protected $di;
/**
* Constructor injects with DI container.
*
* @param Anax\DI\DIInterface $di a service container
*/
public function __construct(ContainerInterface $di)
{
$this->di = $di;
}
/**
* Implementing ArrayAccess for this->elements
*/
public function offsetSet($offset, $value)
{
if (is_null($offset)) {
$this->elements[] = $value;
} else {
$this->elements[$offset] = $value;
}
}
public function offsetExists($offset)
{
return isset($this->elements[$offset]);
}
public function offsetUnset($offset)
{
unset($this->elements[$offset]);
}
public function offsetGet($offset)
{
return isset($this->elements[$offset])
? $this->elements[$offset]
: null;
}
/**
* Add a form element
*
* @param array $form details for the form
* @param array $elements all the elements
*
* @return $this
*/
public function create($form = [], $elements = [])
{
$defaults = [
// Always have an id
"id" => "anax/htmlform",
// Use a default class on <form> to ease styling
"class" => "htmlform",
// Wrap fields within <fieldset>
"use_fieldset" => true,
// Use legend for fieldset, set it to string value
"legend" => null,
// Default wrapper element around form elements
"wrapper-element" => "p",
// Use a <br> after the label, where suitable
"br-after-label" => true,
// Default is to always encode values
"escape-values" => true,
];
$this->form = array_merge($defaults, $form);
$this->elements = [];
if (!empty($elements)) {
foreach ($elements as $key => $element) {
$this->elements[$key] = FormElementFactory::create($key, $element);
$this->elements[$key]->setDefault([
"wrapper-element" => $this->form["wrapper-element"],
"br-after-label" => $this->form["br-after-label"],
"escape-values" => $this->form["escape-values"],
]);
}
}
// Default values for <output>
$this->output = [];
// Setting keys used in the session
$generalKey = "anax/htmlform-" . $this->form["id"] . "#";
$this->sessionKey = [
"save" => $generalKey . "save",
"output" => $generalKey . "output",
"failed" => $generalKey . "failed",
"remember" => $generalKey . "remember",
];
return $this;
}
/**
* Add a form element
*
* @param FormElement $element the formelement to add.
*
* @return $this
*/
public function addElement($element)
{
$name = $element;
if (isset($this->elements[$name])) {
throw new Exception("Form element '$name' already exists, do not add it twice.");
}
$this[$element['name']] = $name;
return $this;
}
/**
* Get a form element
*
* @param string $name the name of the element.
*
* @return \Anax\HTMLForm\FormElement
*/
public function getElement($name)
{
if (!isset($this->elements[$name])) {
throw new Exception("Form element '$name' is not found.");
}
return $this->elements[$name];
}
/**
* Remove an form element
*
* @param string $name the name of the element.
*
* @return $this
*/
public function removeElement($name)
{
if (!isset($this->elements[$name])) {
throw new Exception("Form element '$name' is not found.");
}
unset($this->elements[$name]);
return $this;
}
/**
* Set validation to a form element
*
* @param string $element the name of the formelement to add validation rules to.
* @param array $rules array of validation rules.
*
* @return $this
*/
public function setValidation($element, $rules)
{
$this[$element]['validation'] = $rules;
return $this;
}
/**
* Add output to display to the user for what happened whith the form and
* optionally add a CSS class attribute.
*
* @param string $str the string to add as output.
* @param string $class a class attribute to set.
*
* @return $this.
*/
public function addOutput($str, $class = null)
{
$key = $this->sessionKey["output"];
$session = $this->di->get("session");
$output = $session->get($key);
$output["message"] = isset($output["message"])
? $output["message"] . " $str"
: $str;
if ($class) {
$output["class"] = $class;
}
$session->set($key, $output);
return $this;
}
/**
* Set a CSS class attribute for the <output> element.
*
* @param string $class a class attribute to set.
*
* @return $this.
*/
public function setOutputClass($class)
{
$key = $this->sessionKey["output"];
$session = $this->di->get("session");
$output = $session->get($key);
$output["class"] = $class;
$session->set($key, $output);
return $this;
}
/**
* Remember current values in session, useful for storing values of
* current form when submitting it.
*
* @return $this.
*/
public function rememberValues()
{
$this->rememberValues = true;
return $this;
}
/**
* Get value of a form element and respect settings of escaped or
* raw value.
*
* @param string $name the name of the formelement.
*
* @return mixed the value of the element.
*/
public function value($name)
{
return isset($this->elements[$name])
? $this->elements[$name]->value()
: null;
}
/**
* Get escaped value of a form element.
*
* @param string $name the name of the formelement.
*
* @return mixed the value of the element.
*/
public function escValue($name)
{
return isset($this->elements[$name])
? $this->elements[$name]->getEscapedValue()
: null;
}
/**
* Get raw value of a form element.
*
* @param string $name the name of the formelement.
*
* @return mixed the value of the element.
*/
public function rawValue($name)
{
return isset($this->elements[$name])
? $this->elements[$name]->getRawValue()
: null;
}
/**
* Check if a element is checked
*
* @param string $name the name of the formelement.
*
* @return mixed the value of the element.
*/
public function checked($name)
{
return isset($this->elements[$name])
? $this->elements[$name]->checked()
: null;
}
/**
* Return HTML for the form.
*
* @param array $options with options affecting the form output.
*
* @return string with HTML for the form.
*/
public function getHTML($options = [])
{
$defaults = [
// Only return the start of the form element
'start' => false,
// Layout all elements in one column
'columns' => 1,
// Layout consequtive buttons as one element wrapped in <p>
'use_buttonbar' => true,
];
$options = array_merge($defaults, $options);
$form = array_merge($this->form, $options);
$id = isset($form['id']) ? " id='{$form['id']}'" : null;
$class = isset($form['class']) ? " class='{$form['class']}'" : null;
$name = isset($form['name']) ? " name='{$form['name']}'" : null;
$action = isset($form['action']) ? " action='{$form['action']}'" : null;
$method = isset($form['method']) ? " method='{$form['method']}'" : " method='post'";
$enctype = isset($form['enctype']) ? " enctype='{$form['enctype']}'" : null;
$cformId = isset($form['id']) ? "{$form['id']}" : null;
if ($options['start']) {
return "<form{$id}{$class}{$name}{$action}{$method}>\n";
}
$fieldsetStart = '<fieldset>';
$legend = null;
$fieldsetEnd = '</fieldset>';
if (!$form['use_fieldset']) {
$fieldsetStart = $fieldsetEnd = null;
}
if ($form['use_fieldset'] && $form['legend']) {
$legend = "<legend>{$form['legend']}</legend>";
}
$elementsArray = $this->getHTMLForElements($options);
$elements = $this->getHTMLLayoutForElements($elementsArray, $options);
$output = $this->getOutput();
$html = <<< EOD
\n<form{$id}{$class}{$name}{$action}{$method}{$enctype}>
<input type="hidden" name="anax/htmlform-id" value="$cformId" />
{$fieldsetStart}
{$legend}
{$elements}
{$output}
{$fieldsetEnd}
</form>\n
EOD;
return $html;
}
/**
* Return HTML for the elements
*
* @param array $options with options affecting the form output.
*
* @return array with HTML for the formelements.
*/
public function getHTMLForElements($options = [])
{
$defaults = [
"use_buttonbar" => true,
];
$options = array_merge($defaults, $options);
$elements = [];
$buttonbarStarted = false;
foreach ($this->elements as $element) {
if ($options["use_buttonbar"]) {
if ($element->isButton() && !$buttonbarStarted) {
$buttonbarStarted = true;
$elements[] = [
"name" => "buttonbar start",
"html" => "\n<p class=\"buttonbar\">\n"
];
} elseif ($buttonbarStarted && !$element->isButton()) {
$buttonbarStarted = false;
$elements[] = [
"name" => "buttonbar end",
"html" => "\n</p>\n"
];
}
}
$elements[] = [
"name" => $element["name"],
"html" => $element->getHTML()
];
}
if ($buttonbarStarted) {
$elements[] = [
"name" => "buttonbar end",
"html" => "\n</p>\n"
];
}
return $elements;
}
/**
* Place the elements according to a layout and return the HTML
*
* @param array $elements as returned from GetHTMLForElements().
* @param array $options with options affecting the layout.
*
* @return array with HTML for the formelements.
*/
public function getHTMLLayoutForElements($elements, $options = [])
{
$defaults = [
'columns' => 1,
'wrap_at_element' => false, // Wraps column in equal size or at the set number of elements
];
$options = array_merge($defaults, $options);
$html = null;
if ($options['columns'] === 1) {
foreach ($elements as $element) {
$html .= $element['html'];
}
} elseif ($options['columns'] === 2) {
$buttonbar = null;
$col1 = null;
$col2 = null;
$end = end($elements);
if ($end['name'] == 'buttonbar') {
$end = array_pop($elements);
$buttonbar = "<div class='cform-buttonbar'>\n{$end['html']}</div>\n";
}
$size = count($elements);
$wrapAt = $options['wrap_at_element'] ? $options['wrap_at_element'] : round($size/2);
for ($i=0; $i<$size; $i++) {
if ($i < $wrapAt) {
$col1 .= $elements[$i]['html'];
} else {
$col2 .= $elements[$i]['html'];
}
}
$html = <<<EOD
<div class='cform-columns-2'>
<div class='cform-column-1'>
{$col1}
</div>
<div class='cform-column-2'>
{$col2}
</div>
{$buttonbar}</div>
EOD;
}
return $html;
}
/**
* Get an array with all elements that failed validation together with their id and validation message.
*
* @return array with elements that failed validation.
*/
public function getValidationErrors()
{
$errors = [];
foreach ($this->elements as $name => $element) {
if ($element['validation-pass'] === false) {
$errors[$name] = [
'id' => $element->GetElementId(),
'label' => $element['label'],
'message' => implode(' ', $element['validation-messages'])
];
}
}
return $errors;
}
/**
* Get output messages as <output>.
*
* @return string|null with the complete <output> element or null if no output.
*/
public function getOutput()
{
$output = $this->output;
$message = isset($output["message"]) && !empty($output["message"])
? $output["message"]
: null;
$class = isset($output["class"]) && !empty($output["class"])
? " class=\"{$output["class"]}\""
: null;
return $message
? "<output{$class}>{$message}</output>"
: null;
}
/**
* Init all element with values from session, clear all and fill in with values from the session.
*
* @param array $values retrieved from session
*
* @return void
*/
protected function initElements($values)
{
// First clear all
foreach ($this->elements as $key => $val) {
// Do not reset value for buttons
if (in_array($this[$key]['type'], array('submit', 'reset', 'button'))) {
continue;
}
// Reset the value
$this[$key]['value'] = null;
// Checkboxes must be cleared
if (isset($this[$key]['checked'])) {
$this[$key]['checked'] = false;
}
}
// Now build up all values from $values (session)
foreach ($values as $key => $val) {
// Take care of arrays as values (multiple-checkbox)
if (isset($val['values'])) {
$this[$key]['checked'] = $val['values'];
//$this[$key]['values'] = $val['values'];
} elseif (isset($val['value'])) {
$this[$key]['value'] = $val['value'];
}
if ($this[$key]['type'] === 'checkbox') {
$this[$key]['checked'] = true;
} elseif ($this[$key]['type'] === 'radio') {
$this[$key]['checked'] = $val['value'];
}
// Keep track on validation messages if set
if (isset($val['validation-messages'])) {
$this[$key]['validation-messages'] = $val['validation-messages'];
$this[$key]['validation-pass'] = false;
}
}
}
/**
* Check if a form was submitted and perform validation and call callbacks.
* The form is stored in the session if validation or callback fails. The
* page should then be redirected to the original form page, the form
* will populate from the session and should be rendered again.
* Form elements may remember their value if 'remember' is set and true.
*
* @param callable $callIfSuccess handler to call if function returns true.
* @param callable $callIfFail handler to call if function returns true.
*
* @throws \Anax\HTMLForm\Exception
*
* @return boolean|null $callbackStatus if submitted&validates, false if
* not validate, null if not submitted.
* If submitted the callback function
* will return the actual value which
* should be true or false.
*/
public function check($callIfSuccess = null, $callIfFail = null)
{
$remember = null;
$validates = null;
$callbackStatus = null;
$values = [];
// Remember flash output messages in session
$output = $this->sessionKey["output"];
$session = $this->di->get("session");
$this->output = $session->getOnce($output, []);
// Check if this was a post request
$requestMethod = $this->di->get("request")->getServer("REQUEST_METHOD");
if ($requestMethod !== "POST") {
// Its not posted, but check if values should be used from session
$failed = $this->sessionKey["failed"];
$remember = $this->sessionKey["remember"];
$save = $this->sessionKey["save"];
if ($session->has($failed)) {
// Read form data from session if the previous post failed
// during validation.
$this->InitElements($session->getOnce($failed));
} elseif ($session->has($remember)) {
// Read form data from session if some form elements should
// be remembered
foreach ($session->getOnce($remember) as $key => $val) {
$this[$key]['value'] = $val['value'];
}
} elseif ($session->has($save)) {
// Read form data from session,
// useful during test where the original form is displayed
// with its posted values
$this->InitElements($session->getOnce($save));
}
return null;
}
$request = $this->di->get("request");
$formid = $request->getPost("anax/htmlform-id");
// Check if its a form we are dealing with
if (!$formid) {
return null;
}
// Check if its this form that was posted
if ($this->form["id"] !== $formid) {
return null;
}
// This form was posted, process it
$session->delete($this->sessionKey["failed"]);
$validates = true;
foreach ($this->elements as $element) {
$elementName = $element['name'];
$elementType = $element['type'];
$postElement = $request->getPost($elementName);
if ($postElement) {
// The form element has a value set
// Multiple choices comes in the form of an array
if (is_array($postElement)) {
$values[$elementName]['values'] = $element['checked'] = $postElement;
} else {
$values[$elementName]['value'] = $element['value'] = $postElement;
}
// If the element is a password, do not remember.
if ($elementType === 'password') {
$values[$elementName]['value'] = null;
}
// If the element is a checkbox, set its value of checked.
if ($elementType === 'checkbox') {
$element['checked'] = true;
}
// If the element is a radio, set the value to checked.
if ($elementType === 'radio') {
$element['checked'] = $element['value'];
}
// Do validation of form element
if (isset($element['validation'])) {
$element['validation-pass'] = $element->Validate($element['validation'], $this);
if ($element['validation-pass'] === false) {
$values[$elementName] = [
'value' => $element['value'],
'validation-messages' => $element['validation-messages']
];
$validates = false;
}
}
// Hmmm.... Why did I need this remember thing?
if (isset($element['remember'])
&& $element['remember']
) {
$values[$elementName] = ['value' => $element['value']];
$remember = true;
}
// Carry out the callback if the form element validates
// Hmmm, what if the element with the callback is not the last element?
if (isset($element['callback'])
&& $validates
) {
if (isset($element['callback-args'])) {
$callbackStatus = call_user_func_array(
$element['callback'],
array_merge([$this]),
$element['callback-args']
);
} else {
$callbackStatus = call_user_func($element['callback'], $this);
}
}
} else {
// The form element has no value set
// Set element to null, then we know it was not set.
//$element['value'] = null;
//echo $element['type'] . ':' . $elementName . ':' . $element['value'] . '<br>';
// If the element is a checkbox, clear its value of checked.
if ($element['type'] === 'checkbox'
|| $element['type'] === 'checkbox-multiple'
) {
$element['checked'] = false;
}
// Do validation even when the form element is not set?
// Duplicate code, revise this section and move outside
// this if-statement?
if (isset($element['validation'])) {
$element['validation-pass'] = $element->Validate($element['validation'], $this);
if ($element['validation-pass'] === false) {
$values[$elementName] = [
'value' => $element['value'], 'validation-messages' => $element['validation-messages']
];
$validates = false;
}
}
}
}
// Prepare if data should be stored in the session during redirects
// Did form validation or the callback fail?
if ($validates === false
|| $callbackStatus === false
) {
$session->set($this->sessionKey["failed"], $values);
} elseif ($remember) {
// Hmmm, why do I want to use this
$session->set($this->sessionKey["remember"], $values);
}
if ($this->rememberValues) {
// Remember all posted values
$session->set($this->sessionKey["save"], $values);
}
// Lets se what the return value should be
$ret = $validates
? $callbackStatus
: $validates;
if ($ret === true && isset($callIfSuccess)) {
// Use callback for success, if defined
if (!is_callable($callIfSuccess)) {
throw new Exception("Form, success-method is not callable.");
}
call_user_func_array($callIfSuccess, [$this]);
} elseif ($ret === false && isset($callIfFail)) {
// Use callback for fail, if defined
if (!is_callable($callIfFail)) {
throw new Exception("Form, success-method is not callable.");
}
call_user_func_array($callIfFail, [$this]);
}
return $ret;
}
}