<?php
/**
* Copyright (C) 2017 Karmabunny Pty Ltd.
*
* This file is a part of SproutCMS.
*
* SproutCMS is free software: you can redistribute it and/or modify it under the terms
* of the GNU General Public License as published by the Free Software Foundation, either
* version 2 of the License, or (at your option) any later version.
*
* For more information, visit <http://getsproutcms.com>.
*
* This class was originally from Kohana 2.3.4
* Copyright 2007-2008 Kohana Team
*/
namespace Sprout\Helpers;
use ReflectionMethod;
/**
* Helper functions for outputting form elements.
*
* Wraps form fields (e.g. from {@see Fb}) with additional HTML.
*
* Most wrapping will done using the __callStatic method which actually just calls {@see Form::fieldAuto}.
* That method uses reflection to look for a custom docblock tag, @wrap-in-fieldset.
* If that docblock tag is found, the field is wrapped in {@see Form::fieldFieldset}
* If that docblock tag is not found (the most common case), the field is wrapped in {@see Form::fieldPlain}
*
* If the field being wrapped isn't in the Fb helper, the methods fieldAuto, fieldPlain, and fieldFieldset
* can be invoked directly.
*
* The outermost wrapper DIV around the field has a class of "field-element".
* Additional classes are also added:
* - The method and class name, in the format 'field-element--id-<name>'
* - If an "id" attribute is set, in the format 'field-element--id-<id>'
* - If the field is required, 'field-element--required'
* - If the field is disabled, 'field-element--disabled'
* - If the field has an error, 'field-element--error'
* - One or more custom classes can be specified using the attribute "-wrapper-class".
* Each (array or space separated) class is prefixed with 'field-element--'
*
* @example
* Form::setData($data);
* Form::setErrors($errors);
*
* Form::nextFieldDetails('First name', true);
* echo Form::text('first_name');
*
* Form::nextFieldDetails('Email', true, 'Please enter your email address');
* echo Form::email('email', [], ['-wrapper-class' => 'small']);
*
* Form::nextFieldDetails('Phone', false, 'Enter a phone number using the unique UI');
* echo Form::fieldPlain('SproutModules\Someone\CustomModule\Helpers\FbHack::phone', 'phone', [], []);
*/
class Form
{
static $errors;
static $next_label = null;
static $next_required = null;
static $next_helptext = null;
static $name_format = "%s";
static $id_prefix = '';
/**
* Gets the form per-field value for a single field
*
* As form field datas are stored using the {@see Fb} class, this method just gets the data from there
*
* @param string $field The field name
*/
public static function getData($field)
{
return Fb::getData($field);
}
/**
* Set form per-field values for the fields
*
* As form fields are rendered using the {@see Fb} class, this method just sets the data there
*
* @param array $data In the format
* Key: (string)<field name>
* Value: (string)<field value>
*/
public static
function setData
(array $data) {
Fb::setData($data);
}
/**
* Sets the value for a single field
*
* As form fields are rendered using the {@see Fb} class, this method just sets the data there
*
* @param array $field Field name, e.g. 'first_name'
* @param array $value Field value, e.g. 'John'
* @return void
*/
public static function setFieldValue($field, $value)
{
Fb::setFieldValue($field, $value);
}
/**
* Set per-field error messages to display
*
* A given field can have either a single error message or an array of errors
* The output from the {@see Validator::getFieldErrors} method can be used directly as input to this method
*
* @param array $errors In the format
* Key: (string)<field name>
* Value: (string | array of string)<errors>
*/
public static
function setErrors
(array $errors) {
self::$errors = $errors;
}
/**
* Load data and errors from the session, with optional record id validation
*
* Expected session keys:
* record_id Checked against $verify_record_id, session data is thrown away in case of mismatch
* field_values Field data, loaded using {@see Form::setData}
* field_errors Field errors, loaded using {@see Form::setErrors}
*
* @example
* $data = Form::loadFromSession('register');
* if (empty($data)) {
* $data = $this->add_defaults;
* Form::setData($data);
* }
*
* @param string $key Session key to get values from
* @param mixed $verify_record_id For edit record verification
* @return array Loaded session data
* @return null No session data found
*/
public static function loadFromSession($key, $verify_record_id = null)
{
Session::instance();
if (!empty($verify_record_id) and
!empty($_SESSION[$key]['record_id'])) { if ($_SESSION[$key]['record_id'] != $verify_record_id) {
return null;
}
}
if (!empty($_SESSION[$key]['field_errors'])) { self::setErrors($_SESSION[$key]['field_errors']);
}
if (!empty($_SESSION[$key]['field_values'])) { self::setData($_SESSION[$key]['field_values']);
return $_SESSION[$key]['field_values'];
} else {
return null;
}
}
/**
* Set a format string which will alter the field name prior to being passed to the underlying render method
*
* Formatting is done using {@see sprintf}
* A single parameter is provided to the sprintf() call, the field name
* The default format does no transformation, i.e. the string '%s'
* This parameter persists across multiple form fields
*
* @example
* Form::setFieldNameFormat('pages[%s]')
* Form::text('name') // field name will be 'pages[text]'
*
* @param string $format Format string
*/
public static function setFieldNameFormat($format)
{
self::$name_format = $format;
}
/**
* Sets the prefix for generated IDs
*
* @param string $prefix The prefix
*/
public static function setFieldIdPrefix($prefix)
{
static::$id_prefix = $prefix;
Fb::$id_prefix = $prefix;
}
/**
* Generate a unique id which should be stable across calls to this URL
* as long as the number and order of fields on the page remains the same
*
* @return string 'field?', where ? is an incrementing number starting at zero
*/
protected static function genId()
{
static $inc = 0;
return static::$id_prefix . 'field' . $inc++;
}
/**
* Reset the state machine for field values
*/
public static function resetField()
{
self::$next_label = null;
self::$next_required = null;
self::$next_helptext = null;
}
/**
* Set the details for the next field which will be outputted.
*
* After returning a field, these values will be cleared from the state machine
*
* Both the label and helptext support a subset of HTML, {@see Text::limitedSubsetHtml} for more details
*
* @param string $label Human label for the field (e.g. 'Email address'). Some HTML allowed
* @param bool $required True if this field is required, false if it's optional
* @param string $helptext Optional HTML helptext
*/
public static function nextFieldDetails($label, $required, $helptext = null)
{
self::$next_label = Text::limitedSubsetHtml($label);
self::$next_required = $required;
self::$next_helptext = Text::limitedSubsetHtml($helptext);
}
/**
* Convert a full method name (e.g. Sprout\Helpers\Fb::text) into a friendly class name
*
* The classes {@see Fb} and {@see Form} aren't emitted, but all other class names are
*
* @param string $method Full original method name, in namespace\class::method format
* @return string HTML-safe name for use in a CSS class
*/
protected static function fieldMethodClass($method)
{
$method = str_replace('Sprout\Helpers\Fb::', '', $method); $method = str_replace('Sprout\Helpers\Form::', '', $method); }
/**
* Format a field name as per the specification defined by {@see Form::setFieldNameFormat}
*
* @param string $name Unformatted field name
* @return string Formatted field name
*/
protected static function convertFieldName($name)
{
if (strpos($name, ',') === false) { return sprintf(self::$name_format, $name); }
// Handle compound fields (e.g. Fb::googleMap)
foreach ($fields as &$f) {
$f = sprintf(self::$name_format, $f); }
}
/**
* Return the errors for a given field
*
* Supports nested error arrays; If $field_name is something like member[5][test] then the error
* will be read from self::$errors['member']['5']['test']
*
* @param string $field_name Field to return errors for
* @return array Error messages, as strings
* @return NULL if there aren't any error messages
*/
public static function getFieldErrors($field_name)
{
if (strpos($field_name, '[') === false) { $val = @self::$errors[$field_name];
} else {
// Get a list of keys
foreach ($keys as &$k) {
if ($k == '') return null; // Anon keys not supported
}
// Loop through the keys till we get the value we want
$val = self::$errors;
foreach ($keys as $k) {
$val = @$val[$k];
}
}
return null;
return $val;
} else {
return [$val];
}
}
/**
* Return HTML for a 'plain' field, i.e. one which doesn't require a FIELDSET wrapped around it.
*
* The main wrapping DIV will contain additional classes if the field is required, disabled or has an error.
* A class is also output for the field method name (if the name contains "Sprout\Helpers\Fb::" this is removed)
* If the field has an explicit ID set, that will be added as a class on the wrapper too.
*
* The special attribute "-wrapper-class" can be used to add classes to the wrapper DIV.
* Multiple classes can be specified, space separated.
* These classes will be prefixed with "field-element--"
*
* @example
* echo Form::fieldPlain('Sprout\Helpers\Fb::text', 'first_name', [], []);
*
* @example
* // Adds the class "field-element--id-first-name" to the wrapper
* echo Form::fieldPlain('Sprout\Helpers\Fb::text', 'first_name', ['id' => 'first-name'], []);
*
* @example
* // Adds the class "field-element--small" to the wrapper
* echo Form::fieldPlain('Sprout\Helpers\Fb::text', 'first_name', ['-wrapper-class' => 'small'], []);
*
* @param callable $method The actual field rendering method
* @param string $name The field name - this is passed to the rendering method
* @param array $attrs The field attrs - this is passed to the rendering method
* @param array $options The field options - this is passed to the rendering method
* @return string HTML
*/
public static
function fieldPlain
(callable
$method, $name, array $attrs = [], array $options = []) {
$name = self::convertFieldName($name);
$errs = self::getFieldErrors($name);
$classes = array('field-element'); $classes[] = 'field-element--' . self::fieldMethodClass($method);
if (isset($attrs['id'])) { $classes[] = 'field-element--id-' . Enc::id($attrs['id']);
}
if (self::$next_required) {
$classes[] = 'field-element--required';
}
if (isset($attrs['disabled']) or
in_array('disabled', $attrs, true)) { $classes[] = 'field-element--disabled';
}
$classes[] = 'field-element--error';
}
if (isset($attrs['-wrapper-class'])) { $attrs['-wrapper-class'] = preg_split('/\s+/', $attrs['-wrapper-class']); }
foreach ($attrs['-wrapper-class'] as $class) {
$classes[] = 'field-element--' . $class;
}
unset($attrs['-wrapper-class']); }
$out = '<div class="' . Enc::html($classes) . '">';
if (!isset($attrs['id'])) { $attrs['id'] = self::genId();
}
// It is invalid to output a LABEL without a corresponding element
// check if the ID exists in the field
$has_id_attr = (strpos($field_html, 'id="' . $attrs['id'] . '"') !== false);
// Label section
if (self::$next_label) {
$out .= '<div class="field-label">';
if ($has_id_attr) {
$out .= '<label for="' . Enc::html($attrs['id']) . '">';
}
$out .= self::$next_label;
if (self::$next_required) {
$out .= ' <span class="field-label__required">required</span>';
}
if ($has_id_attr) {
$out .= '</label>';
}
if (self::$next_helptext) {
$out .= '<div class="field-helper">' . self::$next_helptext . '</div>';
}
$out .= '</div>';
}
// Field itself
$out .= '<div class="field-input">';
$out .= $field_html;
$out .= '</div>';
// Field errors
$out .= '<div class="field-error">';
$out .= '<ul class="field-error__list">';
foreach ($errs as $err) {
$out .= '<li class="field-error__list__item">' . Enc::html($err) . '</li>';
}
$out .= '</ul>';
$out .= '</div>';
}
$out .= '</div>';
$out .= PHP_EOL . PHP_EOL;
self::resetField();
return $out;
}
/**
* Return HTML for a field wrapped in a FIELDSET
*
* The main wrapping DIV will contain additional classes if the field is required, disabled or has an error.
* A class is also output for hte field method name (if the name contains "Sprout\Helpers\Fb::" this is removed)
* If the field has an explicit ID set, that will be added as a class on the wrapper too.
*
* The special attribute "-wrapper-class" can be used to add classes to the wrapper DIV.
* Multiple classes can be specified, space separated.
* These classes will be prefixed with "field-element--"
*
* @param callable $method The actual field rendering method
* @param string $name The field name - this is passed to the rendering method
* @param array $attrs The field attrs - this is passed to the rendering method
* @param array $options The field options - this is passed to the rendering method
* @return string HTML
*/
public static
function fieldFieldset
(callable
$method, $name, array $attrs = [], array $options = []) {
$name = self::convertFieldName($name);
$errs = self::getFieldErrors($name);
$classes = array('field-element'); $classes[] = 'field-element--' . self::fieldMethodClass($method);
if (isset($attrs['id'])) { $classes[] = 'field-element--id-' . Enc::id($attrs['id']);
}
if (self::$next_required) {
$classes[] = 'field-element--required';
}
if (isset($attrs['disabled']) or
in_array('disabled', $attrs, true)) { $classes[] = 'field-element--disabled';
}
$classes[] = 'field-element--error';
}
if (isset($attrs['-wrapper-class'])) { $attrs['-wrapper-class'] = preg_split('/\s+/', $attrs['-wrapper-class']); }
foreach ($attrs['-wrapper-class'] as $class) {
$classes[] = 'field-element--' . $class;
}
unset($attrs['-wrapper-class']); }
if (!isset($attrs['id'])) { $attrs['id'] = self::genId();
}
$out = '<div class="' . Enc::html($classes) . '">';
$out .= '<fieldset class="fieldset--' . self::fieldMethodClass($method) . '">';
// Label section
if (self::$next_label) {
$out .= '<legend class="fieldset__legend">';
$out .= self::$next_label;
if (self::$next_required) {
$out .= ' <span class="field-label__required">required</span>';
}
$out .= '</legend>';
if (self::$next_helptext) {
$out .= '<div class="field-helper">' . self::$next_helptext . '</div>';
}
}
// Field itself
$out .= '<div class="field-element__input-set">';
$out .= '</div>';
$out .= '</fieldset>';
// Field errors
$out .= '<div class="field-error">';
$out .= '<ul class="field-error__list">';
foreach ($errs as $err) {
$out .= '<li class="field-error__list__item">' . Enc::html($err) . '</li>';
}
$out .= '</ul>';
$out .= '</div>';
}
$out .= '</div>';
$out .= PHP_EOL . PHP_EOL;
self::resetField();
return $out;
}
/**
* Return HTML for a field, with the wrapping HTML detected automatically.
*
* To enable fieldset wrapping, add the docblock tag @wrap-in-fieldset to the field generation method
*
* @param callable $method The actual field rendering method
* @param string $name The field name - this is passed to the rendering method
* @param array $attrs The field attrs - this is passed to the rendering method
* @param array $options The field options - this is passed to the rendering method
* @return string HTML
*/
public static
function fieldAuto
(callable
$method, $name, array $attrs = [], array $options = []) {
$use_fieldset = false;
$func = new ReflectionMethod($method);
$comment = $func->getDocComment();
if ($comment and
strpos($comment, '@wrap-in-fieldset') !== false) { $use_fieldset = true;
}
if ($use_fieldset) {
return static::fieldFieldset($method, $name, $attrs, $options);
} else {
return static::fieldPlain($method, $name, $attrs, $options);
}
}
/**
* Auto-wrapper around Fb methods
*
* Will wrap the Fb method with the same name as the called method, e.g. Form::datepicker wraps Fb::datepicker
* Wrapping is done using {@see Form::fieldAuto}
*
* @param string $func Method name
* @param array $args Method arguments
* @return string HTML
*/
public static function __callStatic($func, $args)
{
if (!isset($args[1])) $args[1] = []; if (!isset($args[2])) $args[2] = []; return self::fieldAuto('Sprout\Helpers\Fb::' . $func, $args[0], $args[1], $args[2]);
}
/**
* Returns the first argument
*
* This hacky little method works around the fact that fieldPlain only accepts a method name
*
* @param string $str
* @return string
*/
protected static function passString($str) {
return $str;
}
/**
* Return HTML which has been wrapped in the form field DIVs
*
* @param string $html Content to wrap in the field
* @return string HTML
*/
public static function html($html)
{
return static::fieldPlain('Sprout\Helpers\Form::passString', $html);
}
/**
* Return content which has been HTML-encoded and wrapped in the form field DIVs
*
* @param string $plain Plain text to encode and wrap in the field
* @return string HTML
**/
public static function out($plain)
{
return static::fieldPlain('Sprout\Helpers\Form::passString', Enc::html($plain));
}
/**
* Returns HTML for a text field, using {@see Fb::text} to generate the field itself
*
* @param string $name The name of the input field
* @param array $attrs Extra attributes for the input field
* @return string HTML
*/
public static
function text
($name, array $attrs = []) {
return static::fieldPlain('Sprout\Helpers\Fb::text', $name, $attrs);
}
/**
* Returns HTML for a number field, using {@see Fb::number} to generate the field itself
*
* @param string $name The name of the input field
* @param array $attrs Extra attributes for the input field
* @return string HTML
*/
public static
function number
($name, array $attrs = []) {
return static::fieldPlain('Sprout\Helpers\Fb::number', $name, $attrs);
}
/**
* Returns HTML for a money field, using {@see Fb::money} to generate the field itself
*
* @param string $name The name of the input field
* @param array $attrs Extra attributes for the input field
* @return string HTML
*/
public static
function money
($name, array $attrs = [], array $options = []) {
return static::fieldPlain('Sprout\Helpers\Fb::money', $name, $attrs, $options);
}
/**
* Returns HTML for a password field, using {@see Fb::password} to generate the field itself
*
* @param string $name The name of the input field
* @param array $attrs Extra attributes for the input field
* @return string HTML
*/
public static
function password
($name, array $attrs = []) {
return static::fieldPlain('Sprout\Helpers\Fb::password', $name, $attrs);
}
/**
* Returns HTML for a bunch of radiobuttons, using {@see Fb::multiradio} to generate the fields
*
* @param string $name The name of the input field
* @param array $attrs Extra attributes for the input field
* @return string HTML
*/
public static
function multiradio
($name, array $attrs = [], array $options = []) {
return static::fieldFieldset('Sprout\Helpers\Fb::multiradio', $name, $attrs, $options);
}
/**
* Returns HTML for a list of checkboxes, applying name conversions along the way
*
* Uses {@see Fb::checkboxBoolList} to generate the underlying checkbox list
*
* @param array $checkboxes An array of name => label mappings
* @param array $attrs Extra attributes applied to each checkbox field
* @return string HTML
*/
public static
function checkboxList
(array $checkboxes, array $attrs = []) {
$prefixed_names = [];
foreach ($checkboxes as $name => $label) {
$name = static::convertFieldName($name);
$prefixed_names[$name] = $label;
}
return static::fieldFieldset('Sprout\Helpers\Fb::checkboxBoolList', '', $attrs, $prefixed_names);
}
/**
* Returns HTML for an auto-complete list of records
*
* The form data for this field should be an array of arrays with at least the following keys:
* [
* 'id' => record ID,
* 'value' => title text visible in the list item,
* 'orderkey' => ordinal value for record ordering
* ]
*
* @param string $name Field name
* @param string $attrs Unused
* @param array $options Options; these are passed to the JS
* lookup_url string AJAX lookup URL, {@see Fb::autocomplete}; Required
* min_term_length int Min term length for autocomplete; default = 3
* reorder bool Default = false
* @return string HTML
*/
public static
function autofillList
($name, array $attrs = [], array $options = []) {
if (!isset($options['min_term_length'])) $options['min_term_length'] = 3; if (!isset($options['reorder'])) $options['reorder'] = false; if (!isset($options['single'])) $options['single'] = 'an item';
$search_label = "Search for {$options['single']} to add it to the list:";
$search_field_id = Enc::id("autofill-{$name}-search");
$opts = [
'name' => $name,
'lookup_url' => $options['lookup_url'],
'min_term_length' => $options['min_term_length'],
'reorder' => $options['reorder'],
'single' => $options['single'],
];
$data = Fb::getData($name);
if (empty($data)) $data = []; foreach ($data as &$el) {
$el = Enc::html($el);
continue;
}
foreach ($el as &$val) {
$val = Enc::html($val);
}
}
$out = '<div class="autofill-wrap">';
$out .= '<div class="autofill-search">';
$out .= '<div class="autofill-heading"><label for="' . $search_field_id . '">' . Enc::html($search_label) . '</label></div>';
$out .= self::fieldPlain(
'Sprout\Helpers\Fb::text',
$name . '_search',
['-wrapper-class' => 'white', 'id' => $search_field_id]
);
$out .= '</div>';
$out .= '<script type="application/json" class="autofill-list-opts">' . json_encode($opts) . '</script>'; $out .= '<script type="application/json" class="autofill-list-data">' . json_encode($data) . '</script>'; $out .= '<div class="autofill-list"></div>';
// Field errors
$errs = self::getFieldErrors($name);
$out .= '<div class="field-error">';
$out .= '<ul class="field-error__list">';
foreach ($errs as $err) {
$out .= '<li class="field-error__list__item">' . Enc::html($err) . '</li>';
}
$out .= '</ul>';
$out .= '</div>';
}
$out .= '</div>';
$out .= PHP_EOL . PHP_EOL;
return $out;
}
}