ThinkAdmin/plugin/think-library/tests/FormBuilderTest.php
邹景立 875c3a16d7 feat(form): 增加表单栅格布局模块
新增 FormModules::grid 与 gridColumn,用统一方法生成 1 到 4 列的标准表单栅格,避免各业务表单重复拼接 layui 栅格类名。

在表单渲染壳层中注入标准样式,统一栅格列宽、字段间距、单行说明省略和自动补全下拉层样式,保证弹窗与页面模式展示一致。

补充 FormBuilderTest 覆盖可复用栅格布局、nowrap 说明文案和标准样式输出,防止后续表单布局能力回退。
2026-05-21 00:15:34 +08:00

895 lines
42 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
/**
* +----------------------------------------------------------------------
* | ThinkAdmin Plugin
* +----------------------------------------------------------------------
* | Copyright (c) 2014~2026 ThinkAdmin [ thinkadmin.top ]
* +----------------------------------------------------------------------
* | Official Website: https://thinkadmin.top
* +----------------------------------------------------------------------
* | Licensed: https://mit-license.org
* | Disclaimer: https://thinkadmin.top/disclaimer
* | Vip Rights: https://thinkadmin.top/vip-introduce
* +----------------------------------------------------------------------
* | Gitee Repository: https://gitee.com/zoujingli/ThinkAdmin
* | Github Repository: https://github.com/zoujingli/ThinkAdmin
* +----------------------------------------------------------------------
*/
namespace think\admin\tests;
use PHPUnit\Framework\TestCase;
use think\admin\Controller;
use think\admin\builder\form\FormBuilder;
use think\admin\builder\form\FormComponents;
use think\admin\builder\form\FormChoiceField;
use think\admin\builder\form\module\FormModules;
use think\admin\builder\form\FormSelectField;
use think\admin\builder\form\FormTextField;
use think\admin\builder\form\FormUploadField;
use think\Validate;
/**
* @internal
* @coversNothing
*/
class FormBuilderTest extends TestCase
{
public function testCanCollectSchemaAndPortableValidateRules()
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->fields(function ($fields) {
$fields->text('appid', '小程序', 'AppId', true, '必填应用标识', '^wx[0-9a-z]{16}$', ['maxlength' => 18])
->field([
'type' => 'textarea',
'name' => 'remark',
'title' => '备注',
'required' => true,
'rules' => ['max:100' => '备注最多100个字符'],
'attrs' => ['maxlength' => 100],
]);
});
})->build();
$schema = $builder->toArray();
$rules = $builder->getValidateRules();
$requestRules = $builder->getRequestRules();
$this->assertCount(2, $schema['fields']);
$this->assertSame('$vo', $schema['variable']);
$this->assertSame('appid', $schema['fields'][0]['name']);
$this->assertTrue($schema['fields'][0]['required']);
$this->assertSame('^wx[0-9a-z]{16}$', $schema['fields'][0]['pattern']);
$this->assertSame('', $requestRules['appid.default']);
$this->assertSame('', $requestRules['remark.default']);
$this->assertSame('小程序不能为空!', $rules['appid.require']);
$this->assertSame('小程序格式错误!', $rules['appid.regex:/^wx[0-9a-z]{16}$/']);
$this->assertSame('备注不能为空!', $rules['remark.require']);
$this->assertSame('备注最多100个字符', $rules['remark.max:100']);
}
public function testFieldDefaultsCanRenderAndValidateWithoutInitialVo(): void
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->fields(function ($fields) {
$fields->text('sort', '排序', 'Sort', false, '', null, ['type' => 'number'])->defaultValue(0);
$fields->select('driver', '驱动', 'Driver', false, '', [
'local' => '本地',
'qiniu' => '七牛',
])->defaultValue('local');
$fields->radio('status', '状态', 'Status', '', false)
->options([1 => '启用', 0 => '禁用'])
->defaultValue('1');
$fields->checkbox('scene', '场景', 'Scene', '', false)
->options(['index' => '列表', 'form' => '表单'])
->defaultValue(['index']);
});
})->build();
$requestRules = $builder->getRequestRules();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertSame('0', $requestRules['sort.default']);
$this->assertSame('local', $requestRules['driver.default']);
$this->assertSame('1', $requestRules['status.default']);
$this->assertSame(['index'], $requestRules['scene.default']);
$this->assertStringContainsString('value="{$vo.sort|default=0}"', $html);
$this->assertStringContainsString("!isset(\$vo.driver) and strval('local') eq 'local'", $html);
$this->assertStringContainsString("!isset(\$vo.status) and strval('1')==strval('1')", $html);
$this->assertStringContainsString("!isset(\$vo.scene) and in_array('index',['index'])", $html);
}
public function testCheckboxTemplateUsesRealFieldNameAndVariable()
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->action('/submit')
->variable('data')
->fields(function ($fields) {
$fields->checkbox('roles', '角色', '', 'roles', true);
})->actions(function ($actions) {
$actions->submit();
});
})->build();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertStringContainsString('isset($data.roles)', $html);
$this->assertStringContainsString('name="roles[]"', $html);
$this->assertStringContainsString('{notempty name="data.id"}', $html);
$this->assertStringContainsString('form-builder-schema', $html);
}
public function testUrlPatternRuleEscapesRegexDelimiterForBackendValidation()
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->fields(function ($fields) {
$fields->text('auth_url', '授权跳转入口', 'Getway', true, 'URL pattern', '^https?://.*?auth.*?source=SOURCE');
});
})->build();
$rules = $builder->getValidateRules();
$rule = 'regex:/^https?:\/\/.*?auth.*?source=SOURCE$/';
$this->assertArrayHasKey("auth_url.{$rule}", $rules);
$validate = new Validate();
$this->assertTrue($validate->rule(['auth_url' => $rule])->check([
'auth_url' => 'https://open.cuci.cc/auth?source=SOURCE',
]));
}
public function testCheckAndUploadFieldsCanRenderRemarks()
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->fields(function ($fields) {
$fields->field([
'type' => 'image',
'name' => 'headimg',
'title' => '头像',
'remark' => '上传头像',
])->field([
'type' => 'checkbox',
'name' => 'types',
'title' => '通道',
'vname' => 'types',
'remark' => '选择可用通道',
]);
});
})->build();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertStringContainsString('上传头像', $html);
$this->assertStringContainsString('选择可用通道', $html);
}
public function testUploadFieldsCanRenderRuntimeInitializers(): void
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->fields(function ($fields) {
$fields->image('headimg', '头像')->types('jpg,png');
$fields->video('intro_video', '介绍视频');
$fields->images('gallery', '图集');
});
})->build();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertStringContainsString('data-file="image"', $html);
$this->assertStringContainsString('data-field="headimg"', $html);
$this->assertStringContainsString('data-type="jpg,png"', $html);
$this->assertStringContainsString('data-field="intro_video"', $html);
$this->assertStringContainsString("join('|', \$vo.gallery)", $html);
$this->assertStringNotContainsString('uploadOneImage()', $html);
$this->assertStringContainsString('uploadOneVideo()', $html);
$this->assertStringContainsString('uploadMultipleImage()', $html);
}
public function testImageUploadFieldCanRenderPreviewOnlyMode(): void
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->fields(function ($fields) {
$fields->image('headimg', '头像')->previewOnly();
});
})->build();
$schema = $builder->toArray();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertSame('preview', $schema['fields'][0]['upload']['display'] ?? null);
$this->assertMatchesRegularExpression('/<input[^>]*name="headimg"[^>]*type="hidden"[^>]*data-upload-display="preview"/', $html);
$this->assertStringNotContainsString('data-field="headimg"', $html);
$this->assertStringContainsString('uploadOneImage()', $html);
}
public function testTextFieldInputContentCanRenderRightAddon(): void
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->fields(function ($fields) {
$field = $fields->text('browser_icon', '浏览器小图标', 'Browser Icon', true, '', 'url', [
'placeholder' => '请上传浏览器图标',
]);
$field->inputRightIcon('layui-icon-upload-drag', [
'data-file' => 'btn',
'data-type' => 'png,jpg,jpeg',
'data-field' => 'browser_icon',
]);
});
})->build();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertStringContainsString('name="browser_icon"', $html);
$this->assertStringContainsString('class="pr40 layui-input"', $html);
$this->assertStringContainsString('data-field="browser_icon"', $html);
$this->assertStringContainsString('layui-icon-upload-drag', $html);
$this->assertStringContainsString('onmousedown="event.preventDefault();event.stopPropagation();"', $html);
$this->assertStringContainsString('ontouchstart="event.preventDefault();event.stopPropagation();"', $html);
}
public function testSelectAndStaticChoiceFieldsCanRenderWithoutTemplateVariables()
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->fields(function ($fields) {
$fields->select('table_name', '数据表', 'Table', true, '', [
'system_user' => 'system_user',
'system_menu' => '系统<菜单>',
])->field([
'type' => 'radio',
'name' => 'status',
'title' => '状态',
'options' => [1 => '启用&公开', 0 => '禁用'],
]);
});
})->build();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertStringContainsString('name="table_name"', $html);
$this->assertStringContainsString('value="system_user"', $html);
$this->assertStringContainsString('系统&lt;菜单&gt;', $html);
$this->assertStringContainsString('name="status"', $html);
$this->assertStringContainsString('启用&amp;公开', $html);
$this->assertStringContainsString('禁用', $html);
}
public function testActionsCanCustomizeAttrsAndHtml(): void
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->actions(function ($actions) {
$actions->submit('保存', '', ['data-scene' => 'submit'], 'layui-btn-warm')
->cancel('关闭', '确定关闭吗?', ['data-close-mode' => 'drawer'], 'layui-btn-primary')
->button('预览', 'button', '', ['data-preview' => 'true'], 'layui-btn-normal')
->html('<button type="button" class="layui-btn layui-btn-xs" data-extra="1">更多</button>');
});
})->build();
$schema = $builder->toArray();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertSame('submit', $schema['buttons'][0]['type']);
$this->assertSame('submit', $schema['buttons'][0]['attrs']['type']);
$this->assertSame('submit', $schema['buttons'][0]['attrs']['data-scene']);
$this->assertSame('drawer', $schema['buttons'][1]['attrs']['data-close-mode']);
$this->assertSame('button', $schema['buttons'][2]['type']);
$this->assertSame('html', $schema['buttons'][3]['type']);
$this->assertStringContainsString('data-scene="submit"', $html);
$this->assertStringContainsString('data-close-mode="drawer"', $html);
$this->assertStringContainsString('data-preview="true"', $html);
$this->assertStringContainsString('data-extra="1"', $html);
}
public function testFormLayoutCanSetRootAttrsAndModules(): void
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->attr('id', 'ProfileForm')
->class('profile-form')
->class(['profile-form', 'profile-form-shell'])
->data('scene', 'profile')
->module('editor', ['field' => 'content'])
->fields(function ($fields) {
$fields->text('content', '内容');
});
})->build();
$schema = $builder->toArray();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertSame('ProfileForm', $schema['attrs']['id']);
$this->assertSame('profile', $schema['attrs']['data-scene']);
$this->assertSame('profile-form profile-form-shell layui-form layui-card', $schema['attrs']['class']);
$this->assertSame('form', $schema['attrs']['data-builder-scope']);
$this->assertSame('editor', $schema['modules'][0]['name']);
$this->assertSame('content', $schema['modules'][0]['config']['field']);
$this->assertStringContainsString('id="ProfileForm"', $html);
$this->assertStringContainsString('class="profile-form profile-form-shell layui-form layui-card"', $html);
$this->assertStringContainsString('data-scene="profile"', $html);
$this->assertStringContainsString('data-builder-scope="form"', $html);
$this->assertStringContainsString('data-builder-modules=', $html);
}
public function testFormCanRenderStructuredContentNodes(): void
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$group = $form->section()->class('profile-section')->data('scene', 'profile')->module('region', ['code' => 'cn']);
$group->fields(function ($fields) {
$fields->text('nickname', '用户名称', 'Nickname', true, '请输入用户名称');
});
$form->actionBar(function ($actions) {
$actions->submit('保存', '', ['data-scene' => 'submit']);
})->class('profile-actions')->data('scene', 'actions');
})->build();
$schema = $builder->toArray();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertSame('element', $schema['content'][0]['type']);
$this->assertSame('section', $schema['content'][0]['tag']);
$this->assertSame('profile', $schema['content'][0]['attrs']['data-scene']);
$this->assertSame('region', $schema['content'][0]['modules'][0]['name']);
$this->assertSame('actions', $schema['content'][1]['type']);
$this->assertSame('actions', $schema['content'][1]['attrs']['data-scene']);
$this->assertStringContainsString('class="profile-section"', $html);
$this->assertStringContainsString('class="layui-form-item text-center profile-actions"', $html);
$this->assertStringContainsString('data-scene="actions"', $html);
$this->assertStringContainsString('data-builder-modules=', $html);
}
public function testFormModulesCanRenderReusableGridLayout(): void
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->class('ta-form-nowrap');
FormModules::section($form, [
'title' => '布局',
'description' => '描述信息保持单行显示。',
], function ($section) {
$grid = FormModules::grid($section, 3, 'profile-grid');
FormModules::gridColumn($grid, 3)->fields(function ($fields) {
$fields->text('nickname', '用户名称', 'Nickname', true, '单行说明');
});
FormModules::gridColumn($grid, 3)->fields(function ($fields) {
$fields->text('email', '联系邮箱', 'Email');
});
FormModules::gridColumn($grid, 3)->fields(function ($fields) {
$fields->text('phone', '联系电话', 'Phone');
});
});
})->build();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertStringContainsString('data-form-builder-standard-style', $html);
$this->assertStringContainsString('ta-form-nowrap', $html);
$this->assertStringContainsString('layui-row layui-col-space15 ta-form-grid ta-form-grid-3 profile-grid', $html);
$this->assertStringContainsString('layui-col-xs4 ta-form-grid-col', $html);
$this->assertStringContainsString('width:33.333333%!important', $html);
$this->assertStringContainsString('white-space:nowrap', $html);
}
public function testPageModeCanRenderTitleAndDefaultPadding(): void
{
$builder = $this->newBuilder('form', 'page');
$builder->define(function ($form) {
$form->title('资料编辑')
->headerButton('返回列表', 'button', '', ['data-target-backup' => null], 'layui-btn-primary layui-btn-sm')
->fields(function ($fields) {
$fields->text('nickname', '用户名称');
});
})->build();
$schema = $builder->toArray();
$html = $this->invokePrivate($builder, '_buildFormPage');
$this->assertSame('资料编辑', $schema['title']);
$this->assertSame('pa40', $schema['body_attrs']['class']);
$this->assertSame('返回列表', $schema['header_buttons'][0]['name']);
$this->assertStringContainsString('data-builder-scope="page"', $html);
$this->assertStringContainsString('layui-card-header', $html);
$this->assertStringContainsString('layui-card-line', $html);
$this->assertStringContainsString('pull-right', $html);
$this->assertStringContainsString('layui-icon font-s10 color-desc mr5', $html);
$this->assertStringContainsString('class="layui-card-body"', $html);
$this->assertStringContainsString('class="layui-card-table"', $html);
$this->assertStringContainsString('class="think-box-shadow"', $html);
$this->assertStringContainsString('资料编辑', $html);
$this->assertStringContainsString('返回列表', $html);
$this->assertStringContainsString('class="pa40"', $html);
}
public function testModalModeUsesCurrentDefaultBodyPadding(): void
{
$builder = $this->newBuilder('form', 'modal');
$builder->define(function ($form) {
$form->fields(function ($fields) {
$fields->text('nickname', '用户名称');
});
})->build();
$schema = $builder->toArray();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertSame('layui-card-body pl40 pr40 pt20 pb20', $schema['body_attrs']['class']);
$this->assertStringContainsString('class="layui-card-body pl40 pr40 pt20 pb20"', $html);
}
public function testModuleObjectsCanMutateRootNodeAndFieldParts(): void
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->moduleItem('editor', ['field' => 'content'])
->option('mode', 'markdown');
$section = $form->section()->class('module-section');
$section->moduleItem('region', ['code' => 'cn'])
->option('level', 2);
$form->fields(function ($fields) {
$field = $fields->text('nickname', '用户名称');
$field->label()->moduleItem('tooltip', ['target' => 'nickname'])->option('placement', 'top');
$field->input()->moduleItem('picker', ['target' => 'nickname'])->option('mode', 'dialog');
});
})->build();
$schema = $builder->toArray();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertSame('editor', $schema['modules'][0]['name'] ?? null);
$this->assertSame('markdown', $schema['modules'][0]['config']['mode'] ?? null);
$this->assertSame('region', $schema['content'][0]['modules'][0]['name'] ?? null);
$this->assertSame(2, $schema['content'][0]['modules'][0]['config']['level'] ?? null);
$this->assertSame('tooltip', $schema['fields'][0]['parts']['label']['modules'][0]['name'] ?? null);
$this->assertSame('top', $schema['fields'][0]['parts']['label']['modules'][0]['config']['placement'] ?? null);
$this->assertSame('picker', $schema['fields'][0]['parts']['input']['modules'][0]['name'] ?? null);
$this->assertSame('dialog', $schema['fields'][0]['parts']['input']['modules'][0]['config']['mode'] ?? null);
$this->assertStringContainsString('data-builder-modules=', $html);
$this->assertStringContainsString('markdown', $html);
$this->assertStringContainsString('dialog', $html);
}
public function testAttributeObjectsCanMutateRootAndFieldParts(): void
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->attrsItem()
->id('AttrForm')
->class('attr-form')
->data('scene', 'root');
$form->fields(function ($fields) {
$field = $fields->text('nickname', '用户名称');
$field->attrsItem()->class('field-shell')->data('scene', 'field');
$field->label()->attrsItem()->class('label-bag')->data('scene', 'label');
});
})->build();
$schema = $builder->toArray();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertSame('AttrForm', $schema['attrs']['id'] ?? null);
$this->assertSame('root', $schema['attrs']['data-scene'] ?? null);
$this->assertSame('attr-form layui-form layui-card', $schema['attrs']['class'] ?? null);
$this->assertSame('field', $schema['content'][0]['attrs']['data-scene'] ?? null);
$this->assertSame('label-bag', $schema['fields'][0]['parts']['label']['attrs']['class'] ?? null);
$this->assertSame('label', $schema['fields'][0]['parts']['label']['attrs']['data-scene'] ?? null);
$this->assertStringContainsString('id="AttrForm"', $html);
$this->assertStringContainsString('class="attr-form layui-form layui-card"', $html);
$this->assertStringContainsString('class="field-shell layui-form-item block relative"', $html);
$this->assertStringContainsString('class="label-bag help-label"', $html);
$this->assertStringContainsString('data-scene="label"', $html);
}
public function testFormCanRenderRawHtmlNodes(): void
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->section(function ($group) {
$group->class('profile-section');
$group->html('<div class="profile-tip" data-scene="tip">表单说明</div>');
});
})->build();
$schema = $builder->toArray();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertSame('html', $schema['content'][0]['children'][0]['type']);
$this->assertStringContainsString('class="profile-tip"', $html);
$this->assertStringContainsString('data-scene="tip"', $html);
}
public function testFieldNodeCanMutateStructuredParts(): void
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->fields(function ($fields) {
$fields->text('nickname', '用户名称', 'Nickname', true, '请输入用户名称')
->class('field-shell')
->data('scene', 'profile')
->module('picker', ['target' => 'nickname'])
->label()->class('field-label')->class(['field-label', 'field-label-strong'])->module('tooltip', ['target' => 'nickname'])->end()
->body()->class('field-body')->data('body', 'profile')->end()
->input()->class('input-lg')->data('role', 'primary')->attr('placeholder', '请填写用户名称')->end()
->remarkNode()->class('field-remark')->html('请填写用户名称')->end()
->text('email', '联系邮箱', 'Email');
});
})->build();
$schema = $builder->toArray();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertSame('field', $schema['content'][0]['type']);
$this->assertSame('profile', $schema['content'][0]['attrs']['data-scene']);
$this->assertSame('picker', $schema['content'][0]['modules'][0]['name']);
$this->assertSame('field-label field-label-strong', $schema['fields'][0]['parts']['label']['attrs']['class']);
$this->assertSame('tooltip', $schema['fields'][0]['parts']['label']['modules'][0]['name']);
$this->assertSame('field-body', $schema['fields'][0]['parts']['body']['attrs']['class']);
$this->assertSame('input-lg', $schema['fields'][0]['parts']['input']['attrs']['class']);
$this->assertSame('primary', $schema['fields'][0]['parts']['input']['attrs']['data-role']);
$this->assertSame('请填写用户名称', $schema['fields'][0]['parts']['input']['attrs']['placeholder']);
$this->assertSame('field-remark', $schema['fields'][0]['parts']['remark']['attrs']['class']);
$this->assertStringContainsString('class="field-shell layui-form-item block relative"', $html);
$this->assertStringContainsString('data-scene="profile"', $html);
$this->assertStringContainsString('data-builder-modules=', $html);
$this->assertStringContainsString('class="field-label field-label-strong help-label label-required-prev"', $html);
$this->assertStringContainsString('class="field-body"', $html);
$this->assertStringContainsString('data-body="profile"', $html);
$this->assertStringContainsString('data-role="primary"', $html);
$this->assertStringContainsString('class="input-lg layui-input"', $html);
$this->assertStringContainsString('class="field-remark help-block"', $html);
$this->assertStringContainsString('placeholder="请填写用户名称"', $html);
}
public function testTypedFieldsCanMutateRuntimeSchemaAndRules(): void
{
$builder = $this->newBuilder();
$nodes = [];
$builder->define(function ($form) use (&$nodes) {
$form->fields(function ($fields) use (&$nodes) {
$nodes['text'] = $fields->text('appid', '应用')
->title('应用标识')
->required()
->pattern('^wx[0-9a-z]{16}$')
->rule('max:18', '应用标识最多18位字符')
->placeholder('请输入 AppId');
$nodes['text']->maxlength(18)->readonly();
$nodes['select'] = $fields->select('status', '状态', 'Status', false, '', [1 => '启用', 0 => '禁用']);
$nodes['select']->source('statusOptions')->search()->option(2, '待审核');
$nodes['choice'] = $fields->checkbox('roles', '角色', '', 'roles');
$nodes['choice']->source('roleOptions')->required();
$nodes['upload'] = $fields->image('cover', '封面');
$nodes['upload']->types('png,webp');
});
})->build();
$schema = $builder->toArray();
$rules = $builder->getValidateRules();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertInstanceOf(FormTextField::class, $nodes['text']);
$this->assertInstanceOf(FormSelectField::class, $nodes['select']);
$this->assertInstanceOf(FormChoiceField::class, $nodes['choice']);
$this->assertInstanceOf(FormUploadField::class, $nodes['upload']);
$this->assertSame('请输入 AppId', $schema['fields'][0]['attrs']['placeholder']);
$this->assertSame(18, $schema['fields'][0]['attrs']['maxlength']);
$this->assertArrayHasKey('readonly', $schema['fields'][0]['attrs']);
$this->assertSame('statusOptions', $schema['fields'][1]['vname']);
$this->assertArrayHasKey('lay-search', $schema['fields'][1]['attrs']);
$this->assertSame('待审核', $schema['fields'][1]['options']['2']);
$this->assertSame('roleOptions', $schema['fields'][2]['vname']);
$this->assertSame('png,webp', $schema['fields'][3]['upload']['types']);
$this->assertSame('应用标识不能为空!', $rules['appid.require']);
$this->assertSame('应用标识格式错误!', $rules['appid.regex:/^wx[0-9a-z]{16}$/']);
$this->assertSame('应用标识最多18位字符', $rules['appid.max:18']);
$this->assertSame('角色不能为空!', $rules['roles.require']);
$this->assertStringContainsString('placeholder="请输入 AppId"', $html);
$this->assertStringContainsString('data-type="png,webp"', $html);
}
public function testUploadConfigObjectCanMutateSchemaAndRuntime(): void
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->fields(function ($fields) {
$fields->image('cover', '封面')
->uploadConfig()
->types('png,webp')
->triggerClass('upload-trigger')
->triggerIcon('layui-icon-camera')
->triggerAttr('data-scene', 'cover-upload')
->runtimeSelector('#cover-upload')
->runtimeMethod('uploadCoverImage');
});
})->build();
$schema = $builder->toArray();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertSame('png,webp', $schema['fields'][0]['upload']['types'] ?? null);
$this->assertSame('upload-trigger', $schema['fields'][0]['upload']['trigger']['class'] ?? null);
$this->assertSame('layui-icon-camera', $schema['fields'][0]['upload']['trigger']['icon'] ?? null);
$this->assertSame('cover-upload', $schema['fields'][0]['upload']['trigger']['attrs']['data-scene'] ?? null);
$this->assertSame('#cover-upload', $schema['fields'][0]['upload']['runtime']['selector'] ?? null);
$this->assertSame('uploadCoverImage', $schema['fields'][0]['upload']['runtime']['method'] ?? null);
$this->assertStringContainsString('class="layui-icon layui-icon-camera input-right-icon upload-trigger"', $html);
$this->assertStringContainsString('data-scene="cover-upload"', $html);
$this->assertStringContainsString('data-type="png,webp"', $html);
$this->assertStringContainsString('$("#cover-upload").uploadCoverImage()', $html);
}
public function testFieldOptionsObjectsCanMutateSourceAndOptions(): void
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->fields(function ($fields) {
$fields->select('status', '状态', 'Status', false, '', [1 => '启用', 0 => '禁用'])
->optionsItem()
->source('statusOptions')
->option(2, '待审核')
->removeOption(0);
$fields->checkbox('roles', '角色', '', 'roles')
->optionsItem()
->source('roleOptions')
->option('admin', '管理员');
});
})->build();
$schema = $builder->toArray();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertSame('statusOptions', $schema['fields'][0]['vname'] ?? null);
$this->assertSame('启用', $schema['fields'][0]['options']['1'] ?? null);
$this->assertSame('待审核', $schema['fields'][0]['options']['2'] ?? null);
$this->assertArrayNotHasKey('0', $schema['fields'][0]['options']);
$this->assertSame('roleOptions', $schema['fields'][1]['vname'] ?? null);
$this->assertSame('管理员', $schema['fields'][1]['options']['admin'] ?? null);
$this->assertStringContainsString('{foreach $statusOptions as $k=>$v}', $html);
$this->assertStringContainsString('<!--{foreach $roleOptions as $k=>$v}item-->', $html);
}
public function testFormPresetsAndComponentsCanRenderStructuredNodes(): void
{
$builder = $this->newBuilder('form', 'page')->preset('page-form');
$builder->define(function ($form) {
$form->title('资料设置');
$form->component(FormComponents::intro()->config([
'title' => '基础资料',
'description' => '通过组件对象输出表单结构。',
]));
$section = $form->component(FormComponents::section()->config([
'title' => '账号信息',
'description' => '支持 DOM 级插入和字段组合。',
])->body(function ($body) {
$body->component(FormComponents::note('请确认账号信息后再保存。'));
$body->fields(function ($fields) {
$fields->text('nickname', '用户名称');
});
}));
$section->prepend('div', function ($node) {
$node->class('section-prefix')->html('前置提示');
});
})->build();
$schema = $builder->toArray();
$html = $this->invokePrivate($builder, '_buildFormPage');
$this->assertSame('page-form', $schema['preset']);
$this->assertStringContainsString('data-builder-mode="page"', $html);
$this->assertStringContainsString('data-builder-preset="page-form"', $html);
$this->assertStringContainsString('通过组件对象输出表单结构。', $html);
$this->assertStringContainsString('前置提示', $html);
$this->assertStringContainsString('请确认账号信息后再保存。', $html);
}
public function testDialogFormPresetCanExposeExpectedSchema(): void
{
$builder = $this->newBuilder('form', 'modal')->preset('dialog-form')->build();
$schema = $builder->toArray();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertSame('dialog-form', $schema['preset'] ?? null);
$this->assertStringContainsString('data-builder-mode="modal"', $html);
$this->assertStringContainsString('data-builder-preset="dialog-form"', $html);
}
public function testMultipleActionBarsOnlyRenderIdentityFieldOnce(): void
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->variable('data')
->fields(function ($fields) {
$fields->text('nickname', '用户名称');
});
$form->actionBar(function ($actions) {
$actions->submit('保存');
});
$form->actionBar(function ($actions) {
$actions->cancel('关闭');
});
})->build();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertSame(1, substr_count($html, 'name="id"'));
$this->assertSame(1, substr_count($html, '<div class="hr-line-dashed"></div>'));
$this->assertStringContainsString('>保存</button>', $html);
$this->assertStringContainsString('>关闭</button>', $html);
}
public function testFormCanUseDirectObjectAccessors(): void
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->action('/submit')->variable('data');
$form->fieldsNode()->text('nickname', '用户名称')->placeholder('请输入用户名称');
$form->actionsNode()->submit('保存', '', ['data-scene' => 'direct-submit']);
})->build();
$schema = $builder->toArray();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertSame('/submit', $schema['action']);
$this->assertSame('nickname', $schema['fields'][0]['name']);
$this->assertSame('direct-submit', $schema['buttons'][0]['attrs']['data-scene'] ?? null);
$this->assertStringContainsString('placeholder="请输入用户名称"', $html);
$this->assertStringContainsString('data-scene="direct-submit"', $html);
}
public function testFormBodyAttributesCanBeConfiguredByBuilder(): void
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->bodyClass('pa20')
->bodyClass(['profile-body', 'pa20'])
->bodyData('scene', 'profile')
->bodyAttrsItem()
->id('ProfileFormBody')
->attr('data-mode', 'modal')
->end();
$form->fields(function ($fields) {
$fields->text('nickname', '用户名称');
});
})->build();
$schema = $builder->toArray();
$html = $this->invokePrivate($builder, '_buildFormModal');
$bodyClass = strval($schema['body_attrs']['class'] ?? '');
foreach (['layui-card-body', 'pl40', 'pr40', 'pt20', 'pb20', 'pa20', 'profile-body'] as $class) {
$this->assertStringContainsString($class, $bodyClass);
}
$this->assertSame('profile', $schema['body_attrs']['data-scene'] ?? null);
$this->assertSame('modal', $schema['body_attrs']['data-mode'] ?? null);
$this->assertSame('ProfileFormBody', $schema['body_attrs']['id'] ?? null);
$this->assertStringContainsString('id="ProfileFormBody"', $html);
foreach (['layui-card-body', 'pl40', 'pr40', 'pt20', 'pb20', 'pa20', 'profile-body'] as $class) {
$this->assertStringContainsString($class, $html);
}
$this->assertStringContainsString('data-scene="profile"', $html);
$this->assertStringContainsString('data-mode="modal"', $html);
}
public function testRemovingFormNodesKeepsSchemaAndHtmlConsistent(): void
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$field = $form->fieldsNode()->text('name', '名称');
$form->actions(function ($actions) {
$actions->submit('保存');
});
$field->remove();
$form->actionBar()->remove();
})->build();
$schema = $builder->toArray();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertSame([], $schema['content']);
$this->assertSame([], $schema['fields']);
$this->assertSame([], $schema['buttons']);
$this->assertStringNotContainsString('name="name"', $html);
$this->assertStringNotContainsString('>保存</button>', $html);
}
public function testActionBarCanBeRecreatedAfterRemoval(): void
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$bar = $form->actionBar();
$bar->class('first-bar');
$bar->remove();
$form->actionBar()->class('second-bar');
$form->actions(function ($actions) {
$actions->submit('保存');
});
})->build();
$schema = $builder->toArray();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertSame(1, count($schema['buttons']));
$this->assertStringNotContainsString('first-bar', $html);
$this->assertStringContainsString('second-bar', $html);
$this->assertStringContainsString('>保存</button>', $html);
}
public function testFormLayoutRemoveApisCanUpdateRootAttributes(): void
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$form->class('alpha')
->attr('data-scene', 'demo')
->data('mode', 'modal')
->removeClass('alpha')
->removeAttr('data-scene')
->removeData('mode');
})->build();
$schema = $builder->toArray();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertStringNotContainsString('alpha', strval($schema['attrs']['class'] ?? ''));
$this->assertArrayNotHasKey('data-scene', $schema['attrs']);
$this->assertArrayNotHasKey('data-mode', $schema['attrs']);
$this->assertStringNotContainsString('data-scene="demo"', $html);
$this->assertStringNotContainsString('data-mode="modal"', $html);
}
public function testFormNodesPreventCyclicReparenting(): void
{
$builder = $this->newBuilder();
$builder->define(function ($form) {
$outer = $form->div()->class('outer');
$inner = $outer->div()->class('inner');
$inner->appendNode($form);
})->build();
$schema = $builder->toArray();
$html = $this->invokePrivate($builder, '_buildFormModal');
$this->assertCount(1, $schema['content']);
$this->assertSame('outer', $schema['content'][0]['attrs']['class'] ?? null);
$this->assertSame('inner', $schema['content'][0]['children'][0]['attrs']['class'] ?? null);
$this->assertStringContainsString('class="outer"', $html);
$this->assertStringContainsString('class="inner"', $html);
}
private function newBuilder(string $type = 'form', string $mode = 'modal'): FormBuilder
{
$controller = $this->getMockBuilder(Controller::class)->disableOriginalConstructor()->getMock();
return new FormBuilder($type, $mode, $controller);
}
private function invokePrivate(object $object, string $method): mixed
{
$ref = new \ReflectionMethod($object, $method);
$ref->setAccessible(true);
return $ref->invoke($object);
}
}