silverstripe-framework/src/Forms/Form.php

1868 lines
54 KiB
PHP

<?php
namespace SilverStripe\Forms;
use BadMethodCallException;
use SilverStripe\Control\Controller;
use SilverStripe\Control\HasRequestHandler;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Control\Middleware\HTTPCacheControlMiddleware;
use SilverStripe\Control\NullHTTPRequest;
use SilverStripe\Control\RequestHandler;
use SilverStripe\Control\Session;
use SilverStripe\Core\ClassInfo;
use SilverStripe\Core\Convert;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Dev\Deprecation;
use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\DataObjectInterface;
use SilverStripe\ORM\FieldType\DBHTMLText;
use SilverStripe\ORM\ValidationResult;
use SilverStripe\Security\NullSecurityToken;
use SilverStripe\Security\SecurityToken;
use SilverStripe\View\AttributesHTML;
use SilverStripe\View\SSViewer;
use SilverStripe\View\ViewableData;
/**
* Base class for all forms.
* The form class is an extensible base for all forms on a SilverStripe application. It can be used
* either by extending it, and creating processor methods on the subclass, or by creating instances
* of form whose actions are handled by the parent controller.
*
* In either case, if you want to get a form to do anything, it must be inextricably tied to a
* controller. The constructor is passed a controller and a method on that controller. This method
* should return the form object, and it shouldn't require any arguments. Parameters, if necessary,
* can be passed using the URL or get variables. These restrictions are in place so that we can
* recreate the form object upon form submission, without the use of a session, which would be too
* resource-intensive.
*
* You will need to create at least one method for processing the submission (through {@link FormAction}).
* This method will be passed two parameters: the raw request data, and the form object.
* Usually you want to save data into a {@link DataObject} by using {@link saveInto()}.
* If you want to process the submitted data in any way, please use {@link getData()} rather than
* the raw request data.
*
* <h2>Validation</h2>
* Each form needs some form of {@link Validator} to trigger the {@link FormField->validate()} methods for each field.
* You can't disable validator for security reasons, because crucial behaviour like extension checks for file uploads
* depend on it.
* The default validator is an instance of {@link RequiredFields}.
* If you want to enforce serverside-validation to be ignored for a specific {@link FormField},
* you need to subclass it.
*
* <h2>URL Handling</h2>
* The form class extends {@link RequestHandler}, which means it can
* be accessed directly through a URL. This can be handy for refreshing
* a form by ajax, or even just displaying a single form field.
* You can find out the base URL for your form by looking at the
* <form action="..."> value. For example, the edit form in the CMS would be located at
* "admin/EditForm". This URL will render the form without its surrounding
* template when called through GET instead of POST.
*
* By appending to this URL, you can render individual form elements
* through the {@link FormField->FieldHolder()} method.
* For example, the "URLSegment" field in a standard CMS form would be
* accessible through "admin/EditForm/field/URLSegment/FieldHolder".
*/
#[\AllowDynamicProperties]
class Form extends ViewableData implements HasRequestHandler
{
use AttributesHTML;
use FormMessage;
/**
*/
const DEFAULT_NAME = 'Form';
/**
* Form submission data is URL encoded
*/
const ENC_TYPE_URLENCODED = 'application/x-www-form-urlencoded';
/**
* Form submission data is multipart form
*/
const ENC_TYPE_MULTIPART = 'multipart/form-data';
/**
* Accessed by Form.ss; modified by {@link formHtmlContent()}.
* A performance enhancement over the generate-the-form-tag-and-then-remove-it code that was there previously
*
* @var bool
*/
public $IncludeFormTag = true;
/**
* @var FieldList
*/
protected $fields;
/**
* @var FieldList
*/
protected $actions;
/**
* Parent (optional) request handler
*
* @var RequestHandler
*/
protected $controller;
/**
* @var string
*/
protected $name;
/**
* @var Validator
*/
protected $validator;
/**
* @see setValidationResponseCallback()
* @var callable
*/
protected $validationResponseCallback;
/**
* @var string
*/
protected $formMethod = "POST";
/**
* @var boolean
*/
protected $strictFormMethodCheck = true;
/**
* Populated by {@link loadDataFrom()}.
*
* @var DataObject|null
*/
protected $record;
/**
* Keeps track of whether this form has a default action or not.
* Set to false by $this->disableDefaultAction();
*
* @var bool
*/
protected $hasDefaultAction = true;
/**
* Target attribute of form-tag.
* Useful to open a new window upon
* form submission.
*
* @var string|null
*/
protected $target;
/**
* Legend value, to be inserted into the
* <legend> element before the <fieldset>
* in Form.ss template.
*
* @var string|null
*/
protected $legend;
/**
* The SS template to render this form HTML into.
* Default is "Form", but this can be changed to
* another template for customization.
*
* @see Form::setTemplate()
* @var string|array|null
*/
protected $template;
/**
* Should we redirect the user back down to the
* the form on validation errors rather then just the page
*
* @var bool
*/
protected $redirectToFormOnValidationError = false;
/**
* @var bool
*/
protected $security = true;
/**
* @var SecurityToken|null
*/
protected $securityToken = null;
/**
* List of additional CSS classes for the form tag.
*
* @var array
*/
protected $extraClasses = [];
/**
* @config
* @var array $default_classes The default classes to apply to the Form
*/
private static $default_classes = [];
/**
* @var string|null
*/
protected $encType;
/**
* Any custom form attributes set through {@link setAttributes()}.
* Some attributes are calculated on the fly, so please use {@link getAttributes()} to access them.
*
* @var array
*/
protected $attributes = [];
/**
* @var array
*/
protected $validationExemptActions = [];
/**
* @config
* @var array
*/
private static $casting = [
'AttributesHTML' => 'HTMLFragment', // property $AttributesHTML version
'getAttributesHTML' => 'HTMLFragment', // method $getAttributesHTML($arg) version
'FormAttributes' => 'HTMLFragment',
'FormName' => 'Text',
'Legend' => 'HTMLFragment',
];
/**
* @var FormTemplateHelper
*/
private $templateHelper = null;
/**
* HTML ID for this form.
*
* @var string
*/
private $htmlID = null;
/**
* Custom form action path, if not linking to itself.
* E.g. could be used to post to an external link
*
* @var string
*/
protected $formActionPath = false;
/**
* @var bool
*/
protected $securityTokenAdded = false;
/**
* @var bool
*/
protected $notifyUnsavedChanges = false;
/**
* Create a new form, with the given fields an action buttons.
*
* @param RequestHandler $controller Optional parent request handler
* @param string $name The method on the controller that will return this form object.
* @param FieldList $fields All of the fields in the form - a {@link FieldList} of {@link FormField} objects.
* @param FieldList $actions All of the action buttons in the form - a {@link FieldLis} of
* {@link FormAction} objects
* @param Validator|null $validator Override the default validator instance (Default: {@link RequiredFields})
*/
public function __construct(
RequestHandler $controller = null,
$name = self::DEFAULT_NAME,
FieldList $fields = null,
FieldList $actions = null,
Validator $validator = null
) {
parent::__construct();
$fields = $fields ? $fields : FieldList::create();
$actions = $actions ? $actions : FieldList::create();
$fields->setForm($this);
$actions->setForm($this);
$this->fields = $fields;
$this->actions = $actions;
$this->setController($controller);
$this->setName($name);
// Form validation
$this->validator = ($validator) ? $validator : new RequiredFields();
$this->validator->setForm($this);
// Form error controls
$this->restoreFormState();
// Check if CSRF protection is enabled, either on the parent controller or from the default setting. Note that
// method_exists() is used as some controllers (e.g. GroupTest) do not always extend from Object.
if (ClassInfo::hasMethod($controller, 'securityTokenEnabled')) {
$securityEnabled = $controller->securityTokenEnabled();
} else {
$securityEnabled = SecurityToken::is_enabled();
}
$this->securityToken = ($securityEnabled) ? new SecurityToken() : new NullSecurityToken();
$this->setupDefaultClasses();
}
/**
* @return bool
*/
public function getNotifyUnsavedChanges()
{
return $this->notifyUnsavedChanges;
}
/**
* @param bool $flag
*/
public function setNotifyUnsavedChanges($flag)
{
$this->notifyUnsavedChanges = $flag;
}
/**
* Load form state from session state
*
* @return $this
*/
public function restoreFormState()
{
// Restore messages
$result = $this->getSessionValidationResult();
if (isset($result)) {
$this->loadMessagesFrom($result);
}
// load data in from previous submission upon error
$data = $this->getSessionData();
if (isset($data)) {
$this->loadDataFrom($data, self::MERGE_AS_INTERNAL_VALUE);
}
return $this;
}
/**
* Flush persistent form state details
*
* @return $this
*/
public function clearFormState()
{
$this
->getSession()
->clear("FormInfo.{$this->FormName()}.result")
->clear("FormInfo.{$this->FormName()}.data");
return $this;
}
/**
* Helper to get current request for this form
*
* @return HTTPRequest
*/
protected function getRequest()
{
// Check if current request handler has a request object
$controller = $this->getController();
if ($controller && !($controller->getRequest() instanceof NullHTTPRequest)) {
return $controller->getRequest();
}
// Fall back to current controller
if (Controller::has_curr() && !(Controller::curr()->getRequest() instanceof NullHTTPRequest)) {
return Controller::curr()->getRequest();
}
return null;
}
/**
* Get session for this form
*
* @return Session
*/
protected function getSession()
{
$request = $this->getRequest();
if ($request) {
return $request->getSession();
}
throw new BadMethodCallException("Session not available in the current context");
}
/**
* Return any form data stored in the session
*
* @return array
*/
public function getSessionData()
{
return $this->getSession()->get("FormInfo.{$this->FormName()}.data");
}
/**
* Store the given form data in the session
*
* @param array $data
* @return $this
*/
public function setSessionData($data)
{
$this->getSession()->set("FormInfo.{$this->FormName()}.data", $data);
return $this;
}
/**
* Return any ValidationResult instance stored for this object
*
* @return ValidationResult The ValidationResult object stored in the session
*/
public function getSessionValidationResult()
{
$resultData = $this->getSession()->get("FormInfo.{$this->FormName()}.result");
if (isset($resultData)) {
return unserialize($resultData ?? '');
}
return null;
}
/**
* Sets the ValidationResult in the session to be used with the next view of this form.
* @param ValidationResult $result The result to save
* @param bool $combineWithExisting If true, then this will be added to the existing result.
* @return $this
*/
public function setSessionValidationResult(ValidationResult $result, $combineWithExisting = false)
{
// Combine with existing result
if ($combineWithExisting) {
$existingResult = $this->getSessionValidationResult();
if ($existingResult) {
if ($result) {
$existingResult->combineAnd($result);
} else {
$result = $existingResult;
}
}
}
// Serialise
$resultData = $result ? serialize($result) : null;
$this->getSession()->set("FormInfo.{$this->FormName()}.result", $resultData);
return $this;
}
/**
* Clear form message (and in session)
*
* @return $this
*/
public function clearMessage()
{
$this->setMessage(null);
$this->clearFormState();
return $this;
}
/**
* Populate this form with messages from the given ValidationResult.
* Note: This will not clear any pre-existing messages
*
* @param ValidationResult $result
* @return $this
*/
public function loadMessagesFrom($result)
{
// Set message on either a field or the parent form
foreach ($result->getMessages() as $message) {
$fieldName = $message['fieldName'];
if ($fieldName) {
$owner = $this->fields->dataFieldByName($fieldName) ?: $this;
} else {
$owner = $this;
}
$owner->setMessage($message['message'], $message['messageType'], $message['messageCast']);
}
return $this;
}
/**
* Set message on a given field name. This message will not persist via redirect.
*
* @param string $fieldName
* @param string $message
* @param string $messageType
* @param string $messageCast
* @return $this
*/
public function setFieldMessage(
$fieldName,
$message,
$messageType = ValidationResult::TYPE_ERROR,
$messageCast = ValidationResult::CAST_TEXT
) {
$field = $this->fields->dataFieldByName($fieldName);
if ($field) {
$field->setMessage($message, $messageType, $messageCast);
}
return $this;
}
public function castingHelper($field)
{
// Override casting for field message
if (strcasecmp($field ?? '', 'Message') === 0 && ($helper = $this->getMessageCastingHelper())) {
return $helper;
}
return parent::castingHelper($field);
}
/**
* set up the default classes for the form. This is done on construct so that the default classes can be removed
* after instantiation
*/
protected function setupDefaultClasses()
{
$defaultClasses = self::config()->get('default_classes');
if ($defaultClasses) {
foreach ($defaultClasses as $class) {
$this->addExtraClass($class);
}
}
}
/**
* @return callable
*/
public function getValidationResponseCallback()
{
return $this->validationResponseCallback;
}
/**
* Overrules validation error behaviour in {@link httpSubmission()}
* when validation has failed. Useful for optional handling of a certain accepted content type.
*
* The callback can opt out of handling specific responses by returning NULL,
* in which case the default form behaviour will kick in.
*
* @param $callback
* @return self
*/
public function setValidationResponseCallback($callback)
{
$this->validationResponseCallback = $callback;
return $this;
}
/**
* Convert this form into a readonly form
*
* @return $this
*/
public function makeReadonly()
{
$this->transform(new ReadonlyTransformation());
return $this;
}
/**
* Set whether the user should be redirected back down to the
* form on the page upon validation errors in the form or if
* they just need to redirect back to the page
*
* @param bool $bool Redirect to form on error?
* @return $this
*/
public function setRedirectToFormOnValidationError($bool)
{
$this->redirectToFormOnValidationError = $bool;
return $this;
}
/**
* Get whether the user should be redirected back down to the
* form on the page upon validation errors
*
* @return bool
*/
public function getRedirectToFormOnValidationError()
{
return $this->redirectToFormOnValidationError;
}
/**
* @param FormTransformation $trans
*/
public function transform(FormTransformation $trans)
{
$newFields = new FieldList();
foreach ($this->fields as $field) {
$newFields->push($field->transform($trans));
}
$this->fields = $newFields;
$newActions = new FieldList();
foreach ($this->actions as $action) {
$newActions->push($action->transform($trans));
}
$this->actions = $newActions;
// We have to remove validation, if the fields are not editable ;-)
if ($this->validator) {
$this->validator->removeValidation();
}
}
/**
* Get the {@link Validator} attached to this form.
* @return Validator
*/
public function getValidator()
{
return $this->validator;
}
/**
* Set the {@link Validator} on this form.
* @param Validator $validator
* @return $this
*/
public function setValidator(Validator $validator)
{
if ($validator) {
$this->validator = $validator;
$this->validator->setForm($this);
}
return $this;
}
/**
* Remove the {@link Validator} from this from.
*/
public function unsetValidator()
{
$this->validator = null;
return $this;
}
/**
* Set actions that are exempt from validation
*
* @param array $actions
* @return $this
*/
public function setValidationExemptActions($actions)
{
$this->validationExemptActions = $actions;
return $this;
}
/**
* Get a list of actions that are exempt from validation
*
* @return array
*/
public function getValidationExemptActions()
{
return $this->validationExemptActions;
}
/**
* Passed a FormAction, returns true if that action is exempt from Form validation
*
* @param FormAction $action
* @return bool
*/
public function actionIsValidationExempt($action)
{
// Non-actions don't bypass validation
if (!$action) {
return false;
}
if ($action->getValidationExempt()) {
return true;
}
if (in_array($action->actionName(), $this->getValidationExemptActions() ?? [])) {
return true;
}
return false;
}
/**
* Generate extra special fields - namely the security token field (if required).
*
* @return FieldList
*/
public function getExtraFields()
{
$extraFields = new FieldList();
$token = $this->getSecurityToken();
if ($token) {
$tokenField = $token->updateFieldSet($this->fields);
if ($tokenField) {
$tokenField->setForm($this);
}
}
$this->securityTokenAdded = true;
// add the "real" HTTP method if necessary (for PUT, DELETE and HEAD)
if (strtoupper($this->FormMethod() ?? '') != $this->FormHttpMethod()) {
$methodField = new HiddenField('_method', '', $this->FormHttpMethod());
$methodField->setForm($this);
$extraFields->push($methodField);
}
return $extraFields;
}
/**
* Return the form's fields - used by the templates
*
* @return FieldList The form fields
*/
public function Fields()
{
foreach ($this->getExtraFields() as $field) {
if (!$this->fields->fieldByName($field->getName())) {
$this->fields->push($field);
}
}
return $this->fields;
}
/**
* Return all <input type="hidden"> fields
* in a form - including fields nested in {@link CompositeFields}.
* Useful when doing custom field layouts.
*
* @return FieldList
*/
public function HiddenFields()
{
return $this->Fields()->HiddenFields();
}
/**
* Return all fields except for the hidden fields.
* Useful when making your own simplified form layouts.
*/
public function VisibleFields()
{
return $this->Fields()->VisibleFields();
}
/**
* Setter for the form fields.
*
* @param FieldList $fields
* @return $this
*/
public function setFields($fields)
{
$fields->setForm($this);
$this->fields = $fields;
return $this;
}
/**
* Return the form's action buttons - used by the templates
*
* @return FieldList The action list
*/
public function Actions()
{
return $this->actions;
}
/**
* Setter for the form actions.
*
* @param FieldList $actions
* @return $this
*/
public function setActions($actions)
{
$actions->setForm($this);
$this->actions = $actions;
return $this;
}
/**
* Unset all form actions
*/
public function unsetAllActions()
{
$this->actions = new FieldList();
return $this;
}
protected function getDefaultAttributes(): array
{
$attrs = [
'id' => $this->FormName(),
'action' => $this->FormAction(),
'method' => $this->FormMethod(),
'enctype' => $this->getEncType(),
'target' => $this->target,
'class' => $this->extraClass(),
];
if ($this->validator && $this->validator->getErrors()) {
if (!isset($attrs['class'])) {
$attrs['class'] = '';
}
$attrs['class'] .= ' validationerror';
}
return $attrs;
}
public function FormAttributes()
{
return $this->getAttributesHTML();
}
/**
* Set the target of this form to any value - useful for opening the form contents in a new window or refreshing
* another frame
*
* @param string|FormTemplateHelper $helper
*/
public function setTemplateHelper($helper)
{
$this->templateHelper = $helper;
}
/**
* Return a {@link FormTemplateHelper} for this form. If one has not been
* set, return the default helper.
*
* @return FormTemplateHelper
*/
public function getTemplateHelper()
{
if ($this->templateHelper) {
if (is_string($this->templateHelper)) {
return Injector::inst()->get($this->templateHelper);
}
return $this->templateHelper;
}
return FormTemplateHelper::singleton();
}
/**
* Set the target of this form to any value - useful for opening the form
* contents in a new window or refreshing another frame.
*
* @param string $target The value of the target
* @return $this
*/
public function setTarget($target)
{
$this->target = $target;
return $this;
}
/**
* Set the legend value to be inserted into
* the <legend> element in the Form.ss template.
* @param string $legend
* @return $this
*/
public function setLegend($legend)
{
$this->legend = $legend;
return $this;
}
/**
* Set the SS template that this form should use
* to render with. The default is "Form".
*
* @param string|array $template The name of the template (without the .ss extension) or array form
* @return $this
*/
public function setTemplate($template)
{
$this->template = $template;
return $this;
}
/**
* Return the template to render this form with.
*
* @return string|array
*/
public function getTemplate()
{
return $this->template;
}
/**
* Returns the ordered list of preferred templates for rendering this form
* If the template isn't set, then default to the
* form class name e.g "Form".
*
* @return array
*/
public function getTemplates()
{
$templates = SSViewer::get_templates_by_class(static::class, '', __CLASS__);
// Prefer any custom template
if ($this->getTemplate()) {
array_unshift($templates, $this->getTemplate());
}
return $templates;
}
/**
* Returns the encoding type for the form.
*
* By default this will be URL encoded, unless there is a file field present
* in which case multipart is used. You can also set the enc type using
* {@link setEncType}.
*/
public function getEncType()
{
if ($this->encType) {
return $this->encType;
}
if ($fields = $this->fields->dataFields()) {
foreach ($fields as $field) {
if ($field instanceof FileField) {
return self::ENC_TYPE_MULTIPART;
}
}
}
return self::ENC_TYPE_URLENCODED;
}
/**
* Sets the form encoding type. The most common encoding types are defined
* in {@link ENC_TYPE_URLENCODED} and {@link ENC_TYPE_MULTIPART}.
*
* @param string $encType
* @return $this
*/
public function setEncType($encType)
{
$this->encType = $encType;
return $this;
}
/**
* Returns the real HTTP method for the form:
* GET, POST, PUT, DELETE or HEAD.
* As most browsers only support GET and POST in
* form submissions, all other HTTP methods are
* added as a hidden field "_method" that
* gets evaluated in {@link HTTPRequest::detect_method()}.
* See {@link FormMethod()} to get a HTTP method
* for safe insertion into a <form> tag.
*
* @return string HTTP method
*/
public function FormHttpMethod()
{
return $this->formMethod;
}
/**
* Returns the form method to be used in the <form> tag.
* See {@link FormHttpMethod()} to get the "real" method.
*
* @return string Form HTTP method restricted to 'GET' or 'POST'
*/
public function FormMethod()
{
if (in_array($this->formMethod, ['GET','POST'])) {
return $this->formMethod;
} else {
return 'POST';
}
}
/**
* Set the form method: GET, POST, PUT, DELETE.
*
* @param string $method
* @param bool $strict If non-null, pass value to {@link setStrictFormMethodCheck()}.
* @return $this
*/
public function setFormMethod($method, $strict = null)
{
$this->formMethod = strtoupper($method ?? '');
if ($strict !== null) {
$this->setStrictFormMethodCheck($strict);
}
return $this;
}
/**
* If set to true (the default), enforces the matching of the form method.
*
* This will mean two things:
* - GET vars will be ignored by a POST form, and vice versa
* - A submission where the HTTP method used doesn't match the form will return a 400 error.
*
* If set to false then the form method is only used to construct the default
* form.
*
* @param $bool boolean
* @return $this
*/
public function setStrictFormMethodCheck($bool)
{
$this->strictFormMethodCheck = (bool)$bool;
return $this;
}
/**
* @return boolean
*/
public function getStrictFormMethodCheck()
{
return $this->strictFormMethodCheck;
}
/**
* Return the form's action attribute.
* This is build by adding an executeForm get variable to the parent controller's Link() value
*
* @return string
*/
public function FormAction()
{
if ($this->formActionPath) {
return $this->formActionPath;
}
// Get action from request handler link
return $this->getRequestHandler()->Link();
}
/**
* Set the form action attribute to a custom URL.
*
* Note: For "normal" forms, you shouldn't need to use this method. It is
* recommended only for situations where you have two relatively distinct
* parts of the system trying to communicate via a form post.
*
* @param string $path
* @return $this
*/
public function setFormAction($path)
{
$this->formActionPath = $path;
return $this;
}
/**
* Returns the name of the form.
*
* @return string
*/
public function FormName()
{
return $this->getTemplateHelper()->generateFormID($this);
}
/**
* Set the HTML ID attribute of the form.
*
* @param string $id
* @return $this
*/
public function setHTMLID($id)
{
$this->htmlID = $id;
return $this;
}
/**
* @return string
*/
public function getHTMLID()
{
return $this->htmlID;
}
/**
* Get the controller or parent request handler.
*
* @return RequestHandler
*/
public function getController()
{
return $this->controller;
}
/**
* Set the controller or parent request handler.
*
* @param RequestHandler $controller
* @return $this
*/
public function setController(RequestHandler $controller = null)
{
$this->controller = $controller;
return $this;
}
/**
* Get the name of the form.
*
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* Set the name of the form.
*
* @param string $name
* @return Form
*/
public function setName($name)
{
$this->name = $name;
return $this;
}
/**
* Returns an object where there is a method with the same name as each data
* field on the form.
*
* That method will return the field itself.
*
* It means that you can execute $firstName = $form->FieldMap()->FirstName()
*/
public function FieldMap()
{
return new Form_FieldMap($this);
}
/**
* Set a message to the session, for display next time this form is shown.
*
* @param string $message the text of the message
* @param string $type Should be set to good, bad, or warning.
* @param string|bool $cast Cast type; One of the CAST_ constant definitions.
* Bool values will be treated as plain text flag.
*/
public function sessionMessage($message, $type = ValidationResult::TYPE_ERROR, $cast = ValidationResult::CAST_TEXT)
{
$this->setMessage($message, $type, $cast);
$result = $this->getSessionValidationResult() ?: ValidationResult::create();
$result->addMessage($message, $type, null, $cast);
$this->setSessionValidationResult($result);
}
/**
* Set an error to the session, for display next time this form is shown.
*
* @param string $message the text of the message
* @param string $type Should be set to good, bad, or warning.
* @param string|bool $cast Cast type; One of the CAST_ constant definitions.
* Bool values will be treated as plain text flag.
*/
public function sessionError($message, $type = ValidationResult::TYPE_ERROR, $cast = ValidationResult::CAST_TEXT)
{
$this->setMessage($message, $type, $cast);
$result = $this->getSessionValidationResult() ?: ValidationResult::create();
$result->addError($message, $type, null, $cast);
$this->setSessionValidationResult($result);
}
/**
* Set an error message for a field in the session, for display next time this form is shown.
*
* @param string $message the text of the message
* @param string $fieldName Name of the field to set the error message on it.
* @param string $type Should be set to good, bad, or warning.
* @param string|bool $cast Cast type; One of the CAST_ constant definitions.
* Bool values will be treated as plain text flag.
*/
public function sessionFieldError($message, $fieldName, $type = ValidationResult::TYPE_ERROR, $cast = ValidationResult::CAST_TEXT)
{
$this->setMessage($message, $type, $cast);
$result = $this->getSessionValidationResult() ?: ValidationResult::create();
$result->addFieldMessage($fieldName, $message, $type, null, $cast);
$this->setSessionValidationResult($result);
}
/**
* Returns the DataObject that has given this form its data
* through {@link loadDataFrom()}.
*
* @return DataObject
*/
public function getRecord()
{
return $this->record;
}
/**
* Get the legend value to be inserted into the
* <legend> element in Form.ss
*
* @return string
*/
public function getLegend()
{
return $this->legend;
}
/**
* Processing that occurs before a form is executed.
*
* This includes form validation, if it fails, we throw a ValidationException
*
* This includes form validation, if it fails, we redirect back
* to the form with appropriate error messages.
* Always return true if the current form action is exempt from validation
*
* Triggered through {@link httpSubmission()}.
*
*
* Note that CSRF protection takes place in {@link httpSubmission()},
* if it fails the form data will never reach this method.
*
* @return ValidationResult
*/
public function validationResult()
{
// Automatically pass if there is no validator, or the clicked button is exempt
// Note: Soft support here for validation with absent request handler
$handler = $this->getRequestHandler();
$action = $handler ? $handler->buttonClicked() : null;
$validator = $this->getValidator();
if (!$validator || $this->actionIsValidationExempt($action)) {
return ValidationResult::create();
}
// Invoke validator
$result = $validator->validate();
$this->loadMessagesFrom($result);
return $result;
}
const MERGE_DEFAULT = 0b0000;
const MERGE_CLEAR_MISSING = 0b0001;
const MERGE_IGNORE_FALSEISH = 0b0010;
const MERGE_AS_INTERNAL_VALUE = 0b0100;
const MERGE_AS_SUBMITTED_VALUE = 0b1000;
/**
* Load data from the given DataObject or array.
*
* It will call $object->MyField to get the value of MyField.
* If you passed an array, it will call $object[MyField].
* Doesn't save into dataless FormFields ({@link DatalessField}),
* as determined by {@link FieldList->dataFields()}.
*
* By default, if a field isn't set (as determined by isset()),
* its value will not be saved to the field, retaining
* potential existing values.
*
* Passed data should not be escaped, and is saved to the FormField instances unescaped.
* Escaping happens automatically on saving the data through {@link saveInto()}.
*
* Escaping happens automatically on saving the data through
* {@link saveInto()}.
*
* @uses FieldList::dataFields()
* @uses FormField::setSubmittedValue()
* @uses FormField::setValue()
*
* @param array|DataObject $data
* @param int $mergeStrategy
* For every field, {@link $data} is interrogated whether it contains a relevant property/key, and
* what that property/key's value is.
*
* By default, if {@link $data} does contain a property/key, the fields value is always replaced by {@link $data}'s
* value, even if that value is null/false/etc. Fields which don't match any property/key in {@link $data} are
* "left alone", meaning they retain any previous value.
*
* You can pass a bitmask here to change this behaviour.
*
* Passing MERGE_CLEAR_MISSING means that any fields that don't match any property/key in
* {@link $data} are cleared.
*
* Passing MERGE_IGNORE_FALSEISH means that any false-ish value in {@link $data} won't replace
* a field's value.
*
* Passing MERGE_AS_INTERNAL_VALUE forces the data to be parsed using the internal representation of the matching
* form field. This is helpful if you are loading an array of values retrieved from `Form::getData()` and you
* do not want them parsed as submitted data. MERGE_AS_SUBMITTED_VALUE does the opposite and forces the data to be
* parsed as it would be submitted from a form.
*
* For backwards compatibility reasons, this parameter can also be set to === true, which is the same as passing
* MERGE_CLEAR_MISSING
*
* @param array $fieldList An optional list of fields to process. This can be useful when you have a
* form that has some fields that save to one object, and some that save to another.
* @return $this
*/
public function loadDataFrom($data, $mergeStrategy = 0, $fieldList = null)
{
if (!is_object($data) && !is_array($data)) {
user_error("Form::loadDataFrom() not passed an array or an object", E_USER_WARNING);
return $this;
}
// Handle the backwards compatible case of passing "true" as the second argument
if ($mergeStrategy === true) {
$mergeStrategy = self::MERGE_CLEAR_MISSING;
} elseif ($mergeStrategy === false) {
$mergeStrategy = 0;
}
// If an object is passed, save it for historical reference through {@link getRecord()}
// Also use this to determine if we are loading a submitted form, or loading
// from a dataobject
$submitted = true;
if (is_object($data)) {
$this->record = $data;
$submitted = false;
}
// Using the `MERGE_AS_INTERNAL_VALUE` or `MERGE_AS_SUBMITTED_VALUE` flags users can explicitly specify which
// `setValue` method to use.
if (($mergeStrategy & self::MERGE_AS_INTERNAL_VALUE) == self::MERGE_AS_INTERNAL_VALUE) {
$submitted = false;
} elseif (($mergeStrategy & self::MERGE_AS_SUBMITTED_VALUE) == self::MERGE_AS_SUBMITTED_VALUE) {
$submitted = true;
}
// Don't include fields without data
$dataFields = $this->Fields()->dataFields();
if (!$dataFields) {
return $this;
}
/** @var FormField $field */
foreach ($dataFields as $field) {
$name = $field->getName();
// Skip fields that have been excluded
if ($fieldList && !in_array($name, $fieldList ?? [])) {
continue;
}
// First check looks for (fieldname)_unchanged, an indicator that we shouldn't overwrite the field value
if (is_array($data) && isset($data[$name . '_unchanged'])) {
continue;
}
// Does this property exist on $data?
$exists = false;
// The value from $data for this field
$val = null;
if (is_object($data)) {
// Allow dot-syntax traversal of has-one relations fields
if (strpos($name ?? '', '.') !== false) {
$exists = (
$data->hasMethod('relField')
);
try {
$val = $data->relField($name);
} catch (\LogicException $e) {
// There's no other way to tell whether the relation actually exists
$exists = false;
}
// Regular ViewableData access
} else {
$exists = (
isset($data->$name) ||
$data->hasMethod($name) ||
($data->hasMethod('hasField') && $data->hasField($name))
);
if ($exists) {
$val = $data->__get($name);
}
}
// Regular array access. Note that dot-syntax not supported here
} elseif (is_array($data)) {
if (array_key_exists($name, $data ?? [])) {
$exists = true;
$val = $data[$name];
// PHP turns the '.'s in POST vars into '_'s
} elseif (array_key_exists($altName = str_replace('.', '_', $name ?? ''), $data ?? [])) {
$exists = true;
$val = $data[$altName];
} elseif (preg_match_all('/(.*)\[(.*)\]/U', $name ?? '', $matches)) {
// If field is in array-notation we need to access nested data
//discard first match which is just the whole string
array_shift($matches);
$keys = array_pop($matches);
$name = array_shift($matches);
$name = array_shift($name);
if (array_key_exists($name, $data ?? [])) {
$tmpData = &$data[$name];
// drill down into the data array looking for the corresponding value
foreach ($keys as $arrayKey) {
if ($tmpData && $arrayKey !== '') {
$tmpData = &$tmpData[$arrayKey];
} else {
//empty square brackets means new array
if (is_array($tmpData)) {
$tmpData = array_shift($tmpData);
}
}
}
if ($tmpData) {
$val = $tmpData;
$exists = true;
}
}
}
}
// save to the field if either a value is given, or loading of blank/undefined values is forced
$setValue = false;
if ($exists) {
if ($val != false || ($mergeStrategy & self::MERGE_IGNORE_FALSEISH) != self::MERGE_IGNORE_FALSEISH) {
$setValue = true;
}
} elseif (($mergeStrategy & self::MERGE_CLEAR_MISSING) == self::MERGE_CLEAR_MISSING) {
$setValue = true;
}
// pass original data as well so composite fields can act on the additional information
if ($setValue) {
if ($submitted) {
$field->setSubmittedValue($val, $data);
} else {
$field->setValue($val, $data);
}
}
}
return $this;
}
/**
* Save the contents of this form into the given data object.
* It will make use of setCastedField() to do this.
*
* @param DataObjectInterface $dataObject The object to save data into
* @param FieldList $fieldList An optional list of fields to process. This can be useful when you have a
* form that has some fields that save to one object, and some that save to another.
*/
public function saveInto(DataObjectInterface $dataObject, $fieldList = null)
{
$form = $this;
$dataObject->invokeWithExtensions('onBeforeFormSaveInto', $form, $fieldList);
$dataFields = $this->fields->saveableFields();
$lastField = null;
if ($dataFields) {
foreach ($dataFields as $field) {
// Skip fields that have been excluded
if ($fieldList && is_array($fieldList) && !in_array($field->getName(), $fieldList ?? [])) {
continue;
}
$saveMethod = "save{$field->getName()}";
if ($field->getName() == "ClassName") {
$lastField = $field;
} elseif ($dataObject->hasMethod($saveMethod)) {
$dataObject->$saveMethod($field->dataValue());
} elseif ($field->getName() !== "ID") {
$field->saveInto($dataObject);
}
}
}
if ($lastField) {
$lastField->saveInto($dataObject);
}
$dataObject->invokeWithExtensions('onAfterFormSaveInto', $form, $fieldList);
}
/**
* Get the submitted data from this form through
* {@link FieldList->dataFields()}, which filters out
* any form-specific data like form-actions.
* Calls {@link FormField->dataValue()} on each field,
* which returns a value suitable for insertion into a DataObject
* property.
*
* @return array
*/
public function getData()
{
$dataFields = $this->fields->dataFields();
$data = [];
if ($dataFields) {
/** @var FormField $field */
foreach ($dataFields as $field) {
if ($field->getName()) {
$data[$field->getName()] = $field->dataValue();
}
}
}
return $data;
}
/**
* Return a rendered version of this form.
*
* This is returned when you access a form as $FormObject rather
* than <% with FormObject %>
*
* @return DBHTMLText
*/
public function forTemplate()
{
if (!$this->canBeCached()) {
HTTPCacheControlMiddleware::singleton()->disableCache();
}
$context = $this;
$this->extend('onBeforeRender', $context);
$return = $context->renderWith($context->getTemplates());
// Now that we're rendered, clear message
$context->clearMessage();
return $return;
}
/**
* Return a rendered version of this form, suitable for ajax post-back.
*
* It triggers slightly different behaviour, such as disabling the rewriting
* of # links.
*
* @return DBHTMLText
*/
public function forAjaxTemplate()
{
$view = SSViewer::create($this->getTemplates());
$return = $view->dontRewriteHashlinks()->process($this);
// Now that we're rendered, clear message
$this->clearMessage();
return $return;
}
/**
* Returns an HTML rendition of this form, without the <form> tag itself.
*
* Attaches 3 extra hidden files, _form_action, _form_name, _form_method,
* and _form_enctype. These are the attributes of the form. These fields
* can be used to send the form to Ajax.
*
* @deprecated 4.12.0 Will be removed without equivalent functionality
* @return string
*/
public function formHtmlContent()
{
Deprecation::notice('4.12.0', 'Will be removed without equivalent functionality');
$this->IncludeFormTag = false;
$content = $this->forTemplate();
$this->IncludeFormTag = true;
$content .= "<input type=\"hidden\" name=\"_form_action\" id=\"" . $this->FormName() . "_form_action\""
. " value=\"" . $this->FormAction() . "\" />\n";
$content .= "<input type=\"hidden\" name=\"_form_name\" value=\"" . $this->FormName() . "\" />\n";
$content .= "<input type=\"hidden\" name=\"_form_method\" value=\"" . $this->FormMethod() . "\" />\n";
$content .= "<input type=\"hidden\" name=\"_form_enctype\" value=\"" . $this->getEncType() . "\" />\n";
return $content;
}
/**
* Render this form using the given template, and return the result as a string
* You can pass either an SSViewer or a template name
* @param string|array $template
* @return DBHTMLText
*/
public function renderWithoutActionButton($template)
{
$custom = $this->customise([
"Actions" => "",
]);
if (is_string($template)) {
$template = SSViewer::create($template);
}
return $template->process($custom);
}
/**
* Return the default button that should be clicked when another one isn't
* available.
*
* @return FormAction
*/
public function defaultAction()
{
if ($this->hasDefaultAction && $this->actions) {
return $this->actions->flattenFields()->filterByCallback(function ($field) {
return $field instanceof FormAction;
})->first();
}
return null;
}
/**
* Disable the default button.
*
* Ordinarily, when a form is processed and no action_XXX button is
* available, then the first button in the actions list will be pressed.
* However, if this is "delete", for example, this isn't such a good idea.
*
* @return Form
*/
public function disableDefaultAction()
{
$this->hasDefaultAction = false;
return $this;
}
/**
* Disable the requirement of a security token on this form instance. This
* security protects against CSRF attacks, but you should disable this if
* you don't want to tie a form to a session - eg a search form.
*
* Check for token state with {@link getSecurityToken()} and
* {@link SecurityToken->isEnabled()}.
*
* @return Form
*/
public function disableSecurityToken()
{
$this->securityToken = new NullSecurityToken();
return $this;
}
/**
* Enable {@link SecurityToken} protection for this form instance.
*
* Check for token state with {@link getSecurityToken()} and
* {@link SecurityToken->isEnabled()}.
*
* @return Form
*/
public function enableSecurityToken()
{
$this->securityToken = new SecurityToken();
return $this;
}
/**
* Returns the security token for this form (if any exists).
*
* Doesn't check for {@link securityTokenEnabled()}.
*
* Use {@link SecurityToken::inst()} to get a global token.
*
* @return SecurityToken|null
*/
public function getSecurityToken()
{
return $this->securityToken;
}
/**
* Compiles all CSS-classes.
*
* @return string
*/
public function extraClass()
{
return implode(' ', array_unique($this->extraClasses ?? []));
}
/**
* Check if a CSS-class has been added to the form container.
*
* @param string $class A string containing a classname or several class
* names delimited by a single space.
* @return boolean True if all of the classnames passed in have been added.
*/
public function hasExtraClass($class)
{
//split at white space
$classes = preg_split('/\s+/', $class ?? '');
foreach ($classes as $class) {
if (!isset($this->extraClasses[$class])) {
return false;
}
}
return true;
}
/**
* Add a CSS-class to the form-container. If needed, multiple classes can
* be added by delimiting a string with spaces.
*
* @param string $class A string containing a classname or several class
* names delimited by a single space.
* @return $this
*/
public function addExtraClass($class)
{
//split at white space
$classes = preg_split('/\s+/', $class ?? '');
foreach ($classes as $class) {
//add classes one by one
$this->extraClasses[$class] = $class;
}
return $this;
}
/**
* Remove a CSS-class from the form-container. Multiple class names can
* be passed through as a space delimited string
*
* @param string $class
* @return $this
*/
public function removeExtraClass($class)
{
//split at white space
$classes = preg_split('/\s+/', $class ?? '');
foreach ($classes as $class) {
//unset one by one
unset($this->extraClasses[$class]);
}
return $this;
}
public function debug()
{
$class = static::class;
$result = "<h3>$class</h3><ul>";
foreach ($this->fields as $field) {
$result .= "<li>$field" . $field->debug() . "</li>";
}
$result .= "</ul>";
if ($this->validator) {
/** @skipUpgrade */
$result .= '<h3>' . _t(__CLASS__ . '.VALIDATOR', 'Validator') . '</h3>' . $this->validator->debug();
}
return $result;
}
/**
* Current request handler, build by buildRequestHandler(),
* accessed by getRequestHandler()
*
* @var FormRequestHandler
*/
protected $requestHandler = null;
/**
* Get request handler for this form
*
* @return FormRequestHandler
*/
public function getRequestHandler()
{
if (!$this->requestHandler) {
$this->requestHandler = $this->buildRequestHandler();
}
return $this->requestHandler;
}
/**
* Assign a specific request handler for this form
*
* @param FormRequestHandler $handler
* @return $this
*/
public function setRequestHandler(FormRequestHandler $handler)
{
$this->requestHandler = $handler;
return $this;
}
/**
* Scaffold new request handler for this form
*
* @return FormRequestHandler
*/
protected function buildRequestHandler()
{
return FormRequestHandler::create($this);
}
/**
* Can the body of this form be cached?
*
* @return bool
*/
protected function canBeCached()
{
if ($this->getSecurityToken()->isEnabled()) {
return false;
}
if ($this->FormMethod() !== 'GET') {
return false;
}
$validator = $this->getValidator();
if (!$validator) {
return true;
}
return $validator->canBeCached();
}
}