85. 表单进阶

drupal表单系统涉及知识很多,本系列已经发布过两篇表单基础知识,为了接下来“块系统”相关的学习需要提前讲解一些进阶的知识,在阅读本篇时建议你也看一看表单相关的渲染元素类型,熟练掌握渲染器的渲染流程,这将大有帮助,关于表单后续还会出多个章节以介绍ajax、实体表单等。

 

 

子表单:

如果一个表单需要由多个组件共同参与构建,那么在构建表单时,需要调用其他组件来产生属于她们那一部分表单的渲染数组,这部分表单内容称为子表单,创建父表单的组件并不知道子表单代表着什么,那么她的验证和提交怎么处理呢?很显然应该由子表单的来源组件处理。

关于子表单的实现有一种办法是让子表单来源组件通过父表单的修改钩子去添加子表单,并设置验证及提交处理器,但这需要子表单来源组件知道整个表单的结构(因为在获取值和错误设置时需要完整的值路径),这不利于组件间的解耦,有没有更简单的办法呢?如果让子表单来源组件只面对子表单和其值,屏蔽其他表单部分和值将大大减低复杂度,且增加解耦性,这样一来,子表单来源组件好似在处理一个独立表单一样,不需要关注其他信息,要实现这一点,只需要解决值路径问题,让组件使用相对于子表单的路径即可,很棒的是在drupal表单流程中,在验证和提交值的时候,表单数组已经被设置了#parents#array_parents属性,因此可以借此将子表单的值路径从父表单中截取出来,系统使用子表单状态对象来实现这一点:

\Drupal\Core\Form\SubformState

子表单状态对象可以让子表单来源组件像在操作独立表单一样获取值、设置验证错误,在父表单处理组件中使用示例如下:

收集子表单:

        $form['subform'] = [];
        $subformState = SubformState::createForSubform($form['subform'], $form, $form_state);
        $form['subform'] = $this->plugin->buildForm($form['subform'], $subformState);

验证子表单:

    $subformState = SubformState::createForSubform($form['subform'], $form, $form_state);
    $this->plugin->validateForm($form['subform'], $subformState);

提交子表单:

    $subformState = SubformState::createForSubform($form['subform'], $form, $form_state);
    $this->plugin->submitForm($form['subform'], $subformState);

子表单状态对象的主要目的是屏蔽父表单结构,她内部保存了父表单状态对象的引用,提供了获取父表单状态对象的方法,在各种操作时,其内部会补全值路径后作用到父表单的状态对象中。

子表单概念被用在块系统中,块配置实体的表单收集了块插件提供的表单

 

注意:从表单状态对象中获取值时,是根据#parents来查找的,而#parents#tree属性密切相关,由于从子表单状态对象中获取子表单全部值时,这些值被默认为在子表单对应的父表单键名下,所以构建子表单时,最顶层数组的#tree属性必须被设置为true,否则将无法获取值;且子表单的值在值树结构中不可以逃逸出子表单在父表单的键名下,也就是说在子表单中如果元素包含了可输入元素时,该元素不可以出现#treefalse,否则该值无法从子表单状态对象获取,此时这些值会被储存在全局表单状态对象中,如果子表单有意从全局表单对象获取或从元素的#value上获取,那么还是可以的;在父表单中,子表单可以位于多层嵌套的键名下,此时最顶层键名需要设置#treetrue,子表单可以不用再设置

 

如果在子表单构造时就需要知道其在父表单中的位置怎么办?此时可以使用#process属性来设置一个回调,在回调中来执行构造即可,因为在#process回调执行时表单已经被设置了#parents#array_parents属性

 

子表单示例:

为了更加清楚的的说明问题,这里提供一个示例,假设模块名为“yunke_help

表单类如下:

<?php
/**
 * @file
 * 父表单调用子表单演示
 */

namespace Drupal\yunke_help\Form;

use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Form\SubformState;
use Drupal\yunke_help\PluginForm;

/**
 * 该表单作为父表单 她会加载一个其他组件的子表单进来
 */
class TestForm extends FormBase
{

    public $plugin;

    public function __construct()
    {
        $this->plugin = new PluginForm(); //加载其他组件
    }

    public function getFormId()
    {
        return 'yunke_form_test_subform';
    }

    /**
     * {@inheritdoc}
     */
    public function buildForm(array $form, FormStateInterface $form_state)
    {
        //产生子表单
        $form['subform'] = [];
        $subformState = SubformState::createForSubform($form['subform'], $form, $form_state);
        $form['subform'] = $this->plugin->buildForm($form['subform'], $subformState);

        //父表单内容
        $form['phone_number'] = array(
            '#type'          => 'tel',
            '#title'         => '父表单的字段',
            '#default_value' => "13812345678",
        );
        $form['actions']['#type'] = 'actions';
        $form['actions']['submit'] = array(
            '#type'        => 'submit',
            '#value'       => "提交",
            '#button_type' => 'primary',
        );
        $form['actions']['reset'] = array(
            '#type'       => 'html_tag',
            '#tag'        => 'input',
            '#attributes' => ['type' => 'reset', 'value' => "重置", 'class' => "button"],
        );
        $form['#attributes']["target"] = "_blank";
        return $form;
    }

    /**
     * {@inheritdoc}
     */
    public function validateForm(array & $form, FormStateInterface $form_state)
    {
        //验证子表单
        $subformState = SubformState::createForSubform($form['subform'], $form, $form_state);
        $this->plugin->validateForm($form['subform'], $subformState);
        
        //验证附表单
        if (strlen($form_state->getValue('phone_number')) < 5) {
            $form_state->setErrorByName('phone_number', '父表单:电话号码太短');
        }
    }

    /**
     * {@inheritdoc}
     */
    public function submitForm(array & $form, FormStateInterface $form_state)
    {
        //提交子表单
        $subformState = SubformState::createForSubform($form['subform'], $form, $form_state);
        $this->plugin->submitForm($form['subform'], $subformState);

        //提交父表单
        drupal_set_message($this->t('父表单:电话为: @number', array('@number' => $form_state->getValue('phone_number'))));
    }
}

 

组件类如下:

<?php
/**
 * @file
 * 创建子表单
 */

namespace Drupal\yunke_help;

use Drupal\Core\Form\FormStateInterface;

/**
 * 向某个父表单添加子表单
 */
class PluginForm
{

    /**
     * 构建子表单
     */
    public function buildForm(array &$form, FormStateInterface $form_state)
    {
        $form['#tree'] = true; //必须设置,很重要
        $form['a']['b']['c1'] = array(
            '#type'  => 'textfield',
            '#title' => "子表单字段1",
            '#size'  => 100,
        );
        $form['a']['b']['c2'] = array(
            '#type'  => 'textfield',
            '#title' => "子表单字段2",
            '#size'  => 100,
        );
        /*
        $form['s'] = array(
            '#type'  => 'textfield',
            '#title' => "全局值",
            '#size'  => 100,
            '#tree'=>false, //因为这样的设置,该字段的值无法通过子表单状态对象获取
        );
        */
        return $form;
    }

    /**
     * 验证子表单
     */
    public function validateForm(array & $form, FormStateInterface $form_state)
    {
        if (empty($form_state->getValue(['a', 'b', 'c1']))) {
            $form_state->setErrorByName(implode('][', ['a', 'b', 'c1']), "子表单:字段1值不能为空");
        }
    }

    /**
     * 提交子表单
     */
    public function submitForm(array & $form, FormStateInterface $form_state)
    {
        $value = "子表单值:" . $form_state->getValue(['a', 'b', 'c1']);
        drupal_set_message($value);
    }

}

 

可以在控制器中执行如下代码运行:

return \Drupal::formBuilder()->getForm('\Drupal\yunke_help\Form\TestForm');

 

 

验证错误的设置:

在表单状态对象的方法:setErrorByName($name, $message = '')中,参数name是什么含义?她代表一个输入元素,值是用“][”连接#parents属性数组的字符串,注意不是#array_parents

bug提示:在子表单状态对象的以下方法中:

\Drupal\Core\Form\SubformState::setErrorByName

错误的采用了#array_parents做路径,这是不正确的,当子表单在父表单的多层嵌套键名下且顶层键名没有设置#treetrue时,将无法正确的将字段显示为红色

 

改变错误显示方式:

默认情况下,当表单验证有错误时,drupal通过状态数据来储存并显示错误,并将错误字段以红色显示(分配了“error”类),错误显示是可以定制的,这里提供一种方式:

覆写表单错误处理器服务,自定义类继承原错误处理器类:

\Drupal\Core\Form\FormErrorHandler

验证后每个表单元素都会被设置#errors属性,如果不为空说明有错误,在该类中将错误追加到元素后方即可

 

表单对象的实例化:

在普通表单构建器和实体表单构建器中实例化表单对象都是通过类解析器(服务idclass_resolver)进行的,而在表单基类:

\Drupal\Core\Form\FormBase

中实现了以下接口:

\Drupal\Core\DependencyInjection\ContainerInjectionInterface

所以只要表单类继承了该基类或实体表单基类,那么就会通过类的以下方法实例化:

create(ContainerInterface $container)

大多数表单对象都是这样的

 

基本表单id

在本系列表单api中讲到form_idbase_form_id的区别:

form_id用于唯一识别表单,用在主题钩子、表单修改钩子等等地方,base_form_id作为附加的id,可以添加额外的主题钩子和修改钩子

这没有错,但并未讲到为什么要做附加,这主要源于实体,一个实体可能有bundle,表单id应该能够反映出表单是用于实体具体的哪个bundle,因此表单id是如下格式:

实体类型id_ bundle_表单操作名_ form

而实体的基本id是:

实体类型id_ form

这将允许针对实体的通用表单和具体表单进行操作

 

处理器设置:

验证和提交处理器并不是通过类解析器取得的,因此其不能使用服务名,她仅是普通的任意回调,如果需要采用服务做处理器,那么需要实例化服务后将其设置为回调数组,并设置到表单元素上;表单对象中的方法可以采用简写方式:“::+方法名

 

输入值回调:

值回调用于设置表单输入元素的默认值或将用户提交的值处理成系统最终使用的值,可以在元素上以#value_callback指定一个回调,如果没有指定那么寻找函数:'form_type_' . $element['#type'] . '_value',如果还是没有,将使用:\Drupal\Core\Render\Element\FormElement::valueCallback,回调函数签名为:

valueCallback (&$element, $input, &$form_state)

如果返回NULL,意味着直接使用用户提交的值,如果$input全等于false,意味着在寻找默认值

如果以上都找不到输入元素的值(用户没有输入的字段被提交后默认为空字符串,这是有值的),那么将使用元素上的#default_value值,如果也没有则使用空字符串,处理后的值被储存在元素的#value属性上,经过值处理后提交元素(#input= TRUE)一定会有#value属性存在,该值会被储存在表单状态对象中

 

表单关键步骤执行流程:

1、从表单对象取回表单渲染数组,并设置必要的class属性和表单id

2、预备表单:设置表单类型、action、构建id、表单idtoken、默认处理器、主题钩子、派发修改钩子

3、判断当前流程是否是在处理提交(依据输入数据中是否有正确的表单id,程序提交始终认为处于提交)

4、转化提交的表单值或设置默认值(此时已有#parents),并将结果保存到表单状态对象,设置触发元素

5、执行各元素'#process'属性数组指定的回调,参数:[&$element, &$form_state, &$complete_form]

6、判断元素可访问性,默认可访问,如果不可访问将继承给子元素

7、遍历子元素,为其设置类型默认属性、#tree#parents#array_parents#weight

8、执行#after_build数组中的回调,参数:[$element, &$form_state]

9、如果在提交表单,则执行验证,如果是程序提交的那么需要在表单状态对象上设置触发元素

10、如果在提交表单,则执行提交处理器并决定响应

 

表单状态处理:

表单输入控件的可见性、必填性、选中、是否展开等等称为她的状态,表单状态是可以依据不同事件而改变的,事件往往发生在页面其他元素上,比如选中某个复选框后,一个文本域变的可见并且需要必填,这个过程就叫做表单状态处理,状态改变是发生在浏览器端的,依赖js,后端php不应该依托前端,该验证的值依然需要验证(前端只是提高使用体验,任意向服务器发送的内容都是可以伪装的,比如你可以使用C语言直接构造数据然后通过http协议向服务器发送),一旦浏览器禁用了js,那么将无法操作,所以我们应该尽可能在禁用情况下也能提供前端的使用。

drupal提供了一整套关于表单状态处理的解决方案,让我们可以很简单就实现状态处理,先看一个列子,有如下的表单数组: 


        $form['m'] = array(
            '#type'  => 'checkbox',
            '#title' => '点击切换',
        );
        $form['n'] = array(
            '#type'   => 'textfield',
            '#title' => '必填设置',
            '#states' => array(
                'visible'  => array(
                    ':input[name="m"]' => array(
                        'checked' => TRUE,
                    ),
                ),
                'required' => array(
                    ':input[name="m"]' => array(
                        'checked' => TRUE,
                    ),
                ),
            ),
        );

这被渲染后浏览器将仅有一个“点击切换”复选框,一旦选中,那么将打开一个文本输入控件,且是必填的,再次点击复选框将消失,不填也可以提交,如你所见drupal状态处理就是这么简单,只需要在控件上设置#states属性即可,她的值是一个数组,键名为状态名,代表状态,有如下:

enableddisabledrequiredoptionalvisibleinvisiblecheckeduncheckedexpandedcollapsed

键值是多个条件构成的数组,用以指定在这些条件都满足时对元素应用键名所示状态,各条件数组的键名是jQuery选择器,用于指向事件发生的元素,键值是条件,只有条件满足状态才被应用,有如下条件:

emptyfilledcheckeduncheckedexpandedcollapsedvalue

在浏览器中状态是由core/drupal.states库来处理的,js文件位于:core/misc/states.js

更多请见官网文档:

https://api.drupal.org/api/drupal/core%21includes%21common.inc/function…

 

文件提交:

需要为文件字段专门指定name属性,和非文件类型的字段不同,她有独立的提交逻辑,表单构建器默认指定的字name通常无法使用,无法从表单状态对象取回值,状态设置也和普通字段有区别,这将在文件处理相关主题中讲解

 

 

表单中#tree属性的含义及用途:

它用于向表单输出一个多维数组方式的name值,表示值是是呈结构关系的,示例如下:


        $form['a']['#tree']=true;
        $form['a']['b']['c'] = array(
            '#type'  => 'textfield',
            '#title' => "嵌套值测试",
            '#size'  => 100,
        );
        $form['a']['b']['x'] = array(
            '#tree' =>false,
            '#type'  => 'textfield',
            '#title' => "嵌套值测试2",
            '#size'  => 100,
        );

将输出:

<input data-drupal-selector="edit-a-b-c" type="text" id="edit-a-b-c" name="a[b][c]" value="" size="100" maxlength="128" class="form-text" />
<input data-drupal-selector="edit-x" type="text" id="edit-x" name="x" value="" size="100" maxlength="128" class="form-text" />

再看以下代码:

        $form['m'][]=array(
            '#type'  => 'textfield',
            '#title' => "嵌套值测试",
            '#size'  => 100,
        );
        $form['m'][]=array(
            '#type'  => 'textfield',
            '#title' => "嵌套值测试",
            '#size'  => 100,
        );

将输出:

<input data-drupal-selector="edit-0" type="text" id="edit-0" value="" size="100" maxlength="128" class="form-text" />
<input data-drupal-selector="edit-1" type="text" id="edit-1" name="1" value="" size="100" maxlength="128" class="form-text" />

因此需要加上$form['m']['#tree'] = true;,将变为:

<input data-drupal-selector="edit-m-0" type="text" id="edit-m-0" name="m[0]" value="" size="100" maxlength="128" class="form-text" />
<input data-drupal-selector="edit-m-1" type="text" id="edit-m-1" name="m[1]" value="" size="100" maxlength="128" class="form-text" />

 

表单值正则验证:

可以使用正则表达式来验证表单输入,在元素上设置#pattern属性即可,其值是正则表达式,在服务器端验证的值是被值回调转化后的值,而不是用户的原始输入,也就是属性#value的值,默认在浏览器端也会验证。示例如下:

        $form['tel'] = array(
            '#type'  => 'textfield',
            '#title' => "电话", 
            '#size'  => 100,
            '#pattern'=>"\d{11}",
        );

这将要求输入11位的数字,否则无法提交,即便是通过特殊手段提交了,在服务器端验证也无法通过

 

表单自动完成提示:

为元素设置#autocomplete_route_name属性,其值是执行自动完成取数据的回调路由名,

可选的用属性#autocomplete_route_parameters指定路由参数,使用以下方法得到回调url

Url::fromRoute($element['#autocomplete_route_name'], $parameters)->toString(TRUE);

 

 

 

 

本书共91小节:

评论 (写第一个评论)