Drupal 11:使用 HTMX 创建级联选择表单
这是我公司探讨 Drupal 中 HTMX 的系列文章的第四部分。在过去的两篇文章里,我们研究了以不同方式在控制器中使用 HTMX。这次,我公司打算涉足 HTMX 和表单的领域。
多年前,我公司曾在网站上发表过一篇关于 Drupal 中级联 Ajax 选择表单的文章。当尝试弄清楚与选择表单和 Ajax 相关的事情时,我公司常常会参考这篇文章。在那篇文章中,我公司创建了年、月、日选择字段,并将它们关联起来,让它们在选择过程中能相互影响。
我公司编写 Drupal 网站已有好些年了,但每次尝试在 Drupal 表单中实现 Ajax 时,仍会感到有些头疼。为了让一切正常工作,最后往往需要在表单字段中添加包装元素或自定义属性,这似乎总是一段不太愉快的经历。
在学习 HTMX 和 Drupal 后,我公司重新实现这个级联选择表单,大概半小时就完成了。大部分时间都花在了将表单元素添加到构建表单的方法中。这和在 Drupal 表单中添加 Ajax 的旧方法形成了鲜明对比。
在本文中,我公司将创建一个包含多个选择元素的表单,然后使用 HTMX(以及一点表单状态 API)将它们关联起来,以便选择一个元素就能更新其他元素。同时,Drupal 开发和 Drupal 模块开发在这个过程中也起到了重要的支撑作用,保证了表单功能的顺利实现。
本文中包含的所有代码都能在相关的示例项目中找到,在这里我们将详细介绍代码的功能以及它为生成内容所执行的操作。
和其他关于 HTMX 的文章一样,我公司将从基础开始,定义路由。
一、路由
我们这里需要的路由只需将路径 /htmx-examples/cascading-select 指向我们的表单类。
drupal_htmx_examples_cascading_select_form:
path: "/htmx-examples/cascading-select"
defaults:
_form: '\Drupal\drupal_htmx_examples\Form\CascadingSelectForm'
_title: "HTMX 级联选择示例表单"
requirements:
_permission: "access content"
这个路由没什么特别的,它只是一个普通的表单路由。
接下来,让我们为这个路由创建表单。
二、表单
表单类只是一个标准的 Drupal 表单,但在控制器中使用 HTMX 和在表单中使用 HTMX 存在区别。当我们定义一个将使用 HTMX 的控制器时,(有时)需要将 request_stack 服务注入到表单中,这样就能用它来检测任何传入的 HTMX 请求。对于表单而言,这不是必需的,因为 request_stack 服务是核心 FormBase 类的一部分,我们在 Drupal 中创建所有表单时都会扩展这个类。
以下是表单类的大纲,它将包含我们的级联选择表单。
<?php
namespace Drupal\drupal_htmx_examples\Form;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Htmx\Htmx;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* 使用 HTMX 展示级联选择的表单。
*/
class CascadingSelectForm extends FormBase {
/**
* {@inheritDoc}
*/
public function getFormId() {
return 'htmx_cascade_select_form';
}
/**
* {@inheritDoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {}
/**
* {@inheritDoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state): void {
$year = $form_state->getValue('year');
$month = $form_state->getValue('month');
$day = $form_state->getValue('day');
$args = [
'%year' => $year,
'%month' => $month,
'%day' => $day,
];
$this->messenger()->addMessage($this->t('已提交表单,值为 年: %year, 月: %month, 日: %day', $args));
}
}
我公司还在这里添加了提交处理程序,它会把输入参数作为消息打印出来。
要构建我们的表单,需要定义年、月、日选择字段,使它们在用户交互过程中能够相互更新。
第一个元素是年份选择,它只是定义了一个从 2019 年到 2050 年的任意日期范围,并将其作为数组提供给一个选择字段。我们还从表单状态中获取当前年份的值,以防用户已经提交了表单,我们希望保持该状态。这里对日期范围的处理可以更细致,但这只是一个测试表单。
$years = range(2019, 2050);
$years = array_combine($years, $years);
$year = $form_state->getValue('year');
$form['year'] = [
'#title' => $this->t('Year'),
'#type' => 'select',
'#empty_option' => $this->t('- Select -'),
'#options' => $years,
'#default_value' => $year,
];
为了定义月份选择,我们只需创建一个小的月份数组(毕竟只有 12 个值),并将其提供给月份选择元素。我们还从表单状态中添加当前月份的值,然后在这里设置表单的 '#states' API,让只有当年份选择有值时才会显示月份选择。
$months = [
1 => $this->t('Jan'),
2 => $this->t('Feb'),
3 => $this->t('Mar'),
4 => $this->t('Apr'),
5 => $this->t('May'),
6 => $this->t('Jun'),
7 => $this->t('Jul'),
8 => $this->t('Aug'),
9 => $this->t('Sep'),
10 => $this->t('Oct'),
11 => $this->t('Nov'),
12 => $this->t('Dec'),
];
$month = $form_state->getValue('month');
$form['month'] = [
'#title' => $this->t('Month'),
'#type' => 'select',
'#options' => $months,
'#empty_option' => $this->t('- Select -'),
'#default_value' => $month,
'#states' => [
'!visible' => [
':input[name="year"]' => ['value' => ''],
],
],
];
创建日期列表稍微复杂一些。有些月份的天数不同[需要引用来源],所以不能简单地添加一个从 1 到 31 的范围就完事了。相反,我们需要监听年份和月份的值,然后使用 PHP 内置函数 cal_days_in_month() 来加载天数。这意味着如果月份是闰年的二月,我们在选择列表中仍然会显示正确的天数。我们还添加了 #states API 属性,让只有当月份元素有值时才会显示日期字段。
$days = [];
if ($month) {
$number = cal_days_in_month(CAL_GREGORIAN, $month, $year);
$days = range(1, $number);
$days = array_combine($days, $days);
}
$day = $form_state->getValue('day');
$form['day'] = [
'#title' => $this->t('Day'),
'#type' => 'select',
'#options' => $days,
'#empty_option' => $this->t('- Select -'),
'#default_value' => $day,
'#states' => [
'!visible' => [
':input[name="month"]' => ['value' => ''],
],
],
];
最后,我们现在只需要添加一个提交按钮并返回表单。
$form['submit'] = [
'#type' => 'submit',
'#value' => 'Submit',
];
return $form;
这个表单会根据状态 API 显示和隐藏元素,但它不会用正确的天数填充日期字段。所以接下来让我们在表单中添加重要的 HTMX。
三、添加 HTMX
这个表单的 HTMX 需要在选择元素定义之后、表单末尾的返回语句之前添加。Drupal 开发中,添加 HTMX 对于实现级联选择表单的动态更新至关重要。
我们需要为表单中的两个字段添加 HTMX 以执行以下操作:
- 当用户选择一个月份时,我们需要回到表单来计算该月的天数,并填充日期选择字段。
- 当用户选择一个年份时,我们需要回去重新填充月份和日期选择字段。这将涵盖选择闰年且用户恰好选择了 2 月 29 日的情况。
我们使用 Htmx Drupal 类来创建我们需要的属性,并将它们应用到月份选择元素上。
(new Htmx())
->post()
->select('*:has(>select[name="day"])')
->target('*:has(>select[name="day"])')
->swap('outerHTML')
->applyTo($form['month']);
这会将以下 HTMX 属性添加到表单元素上。
data-hx-post- 这将向表单发送一个 POST 请求。由于我们没有为该方法添加值,它将自动使用当前路由。data-hx-select- 在处理 Drupal 中的 HTMX 表单时,选择属性至关重要。如果你读过我之前的文章,你会记得当我们发出 HTMX 请求时,我们会从服务器得到整个表单。因此,我们需要告诉 HTMX 从响应中只挑选出我们需要的元素。在这种情况下,我们使用选择器字符串*:has(>select[name="day"],它告诉 HTMX 我们要找到名称为 "day" 的选择元素的父元素,即我们的日期选择元素。data-hx-target- 这告诉 HTMX 将我们从响应中提取的元素放置在哪里。我们使用与 data-hx-select 属性相同的选择器字符串,因为我们想替换页面上的日期选择元素。data-hs-swap- 最后,我们将交换策略设置为 outerHTML,这告诉 HTMX 用响应中选择的值完全替换目标元素。
创建另一个 Htmx Drupal 对象,为年份选择元素添加我们需要的属性。
(new Htmx())
->post()
->select('*:has(>select[name="month"])')
->target('*:has(>select[name="month"])')
// 我们还使用越界选择来针对 edit-day ID(即选择元素),以替换日期选择。
// 这可以处理选择 2 月 29 日且选择非闰年的边缘情况。
->selectOob('#edit-day')
->swap('outerHTML')
->applyTo($form['year']);
年份表单元素的主要新增部分是添加了 selectOob() 方法。这会向年份字段添加一个 data-hx-select-oob 属性,该属性在年份触发的响应中针对日期字段。
如果你读到这里,想知道为什么我可以重用月份字段的 data-hx-select 和 data-hx-target 属性中的选择器字符串,那是因为当使用越界选择时,该选择器似乎会给 HTMX 带来问题。HTMX 似乎希望属性中存在一个 ID,所以如果我们传递一个以 "*" 开头的字符串,它会尝试将其作为一个 ID 名称,如 "#*",这对于选择器来说是无效的语法。
四、HTMX 工作流程
这些 HTMX 元素以以下方式工作。
请注意,我公司简化并删除了相当多的标记,以便于阅读和理解。
我们在这里要做的是选择日期 "2024 年 2 月 29 日",这是一个闰年,然后选择 2023 年,这不是闰年。这会导致日期选择被取消选择,因为记录 29 不再存在。
表单被创建,它包含以下元素。
<div class="js-form-item form-item form-type-select js-form-type-select form-item-year js-form-item-year">
<select data-hx-post="" data-hx-select="*:has(>select[name="month"])" data-hx-target="*:has(>select[name="month"])" data-hx-select-oob="edit-day" data-hx-swap="outerHTML ignoreTitle:true" data-drupal-selector="edit-year" id="edit-year" name="year" class="form-select form-element form-element--type-select">
<option value="" selected="selected">- Select -</option>
<option value="2019">2019</option>
...
<option value="2024">2024</option>
...
<option value="2050">2050</option>
</select>
</div>
<div class="js-form-item form-item form-type-select js-form-type-select form-item-month js-form-item-month" style="display: none;">
<select data-hx-post="" data-hx-select="*:has(>select[name="day"])" data-hx-target="*:has(>select[name="day"])" data-hx-swap="outerHTML ignoreTitle:true" data-drupal-selector="edit-month" id="edit-month" name="month" class="form-select form-element form-element--type-select" data-drupal-states="{"!visible":{":input[name=\u0022year\u0022]":{"value":""}}}" data-once="states">
<option value="" selected="selected">- Select -</option>
<option value="1">Jan</option>
<option value="2">Feb</option>
<option value="3">Mar</option>
<option value="4">Apr</option>
<option value="5">May</option>
<option value="6">Jun</option>
<option value="7">Jul</option>
<option value="8">Aug</option>
<option value="9">Sep</option>
<option value="10">Oct</option>
<option value="11">Nov</option>
<option value="12">Dec</option>
</select>
</div>
<div class="js-form-item form-item form-type-select js-form-type-select form-item-day js-form-item-day" style="display: none;">
<select data-drupal-selector="edit-day" id="edit-day" name="day" 

