bricks/bricks/form.js
Hermes Agent 37f46ed1bd feat: Form validation — rules-based field validation with inline error display
- FormBase: added validate_rules(), _check_rule(), show_field_error(), clear_all_errors()
- FieldGroup: creates hidden error Text widget for fields with rules
- validation(): calls validate_rules() before submit, blocks on failure
- CSS: .inputbox.field-error red border, .field-error-msg red text
- Supported rule types: required, minlength, maxlength, min, max, pattern, email, number
2026-06-18 17:22:11 +08:00

611 lines
14 KiB
JavaScript
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.

var bricks = window.bricks || {};
bricks.need_formdata_fields = ['file', 'video', 'audio'];
bricks.show_resp_message_or_error = async function(resp){
var desc = await resp.json();
await bricks.widgetBuild(desc, bricks.Body);
}
bricks.FieldGroup = class {
constructor(opts){
this.opts = opts
}
build_fields(form, parent, fields){
var dc = new bricks.DynamicColumn({mobile_cols:2});
for (var i=0;i<fields.length;i++){
if (fields[i].uitype == 'group'){
if (dc.children.length>0){
parent.add_widget(dc);
dc = new bricks.DynamicColumn({mobile_cols:2});
}
this.build_fields(form, dc, fields[i].fields);
parent.add_widget(dc);
dc = new bricks.DynamicColumn({mobile_cols:2});
dc.set_id(fields[i].name+'_box');
if (fields[i].nonuse){
dc.disabled(true);
dc.hide();
}
} else {
var box;
if (! form.opts.input_layout || form.opts.input_layout == 'VBox'){
box = new bricks.VBox({height:'auto',overflow:'none'});
} else {
box = new bricks.HBox({height:'auto',overflow:'none'});
}
box.set_css('inputbox');
if (fields[i].uitype !== 'hide'){
dc.add_widget(box);
}
if(bricks.need_formdata_fields.includes(fields[i].uitype)){
form.need_formdata = true;
}
var txt = new bricks.Text({
otext:fields[i].label||fields[i].name,
dynsize:true,
height:'auto',
i18n:true});
box.add_widget(txt);
box.set_id(fields[i].name + '_box')
if (fields[i].nonuse){
box.disabled(true);
box.hide();
}
var w = Input.factory(fields[i]);
if (w){
box.add_widget(w);
form.name_inputs[fields[i].name] = w;
w.set_id(fields[i].name);
if (fields[i].rules && fields[i].rules.length > 0){
var errw = new bricks.Text({
otext:'',
height:'auto',
dynsize:true,
i18n:false,
cfontsize:0.8
});
errw.set_id(fields[i].name + '_error');
errw.set_css('field-error-msg');
errw.hide();
box.add_widget(errw);
form.name_error_widgets[fields[i].name] = errw;
form.field_rules[fields[i].name] = fields[i].rules;
}
} else {
bricks.debug(fields[i], 'createInput failed');
}
}
}
if (dc.children.length > 0){
parent.add_widget(dc);
}
}
}
bricks.FormBody = class extends bricks.VScrollPanel {
/*
{
title:
description:
fields: [
{
"name":,
"label":,
"removable":
"icon":
"content":
},
...
]
exclusionfields:[
[a,b,c], # a,b,c互斥a enabledb,c必须disabled
[x,y] # x,y互斥
]
}
*/
constructor(form, opts){
opts.width = '100%';
opts.height = '100%';
super(opts);
this.form = form;
this.name_inputs = {};
this.fg = new bricks.FieldGroup({});
this.fg.build_fields(form, this, form.nontextfields);
this.build_text_fields();
}
build_text_fields(){
this.form.textfields.forEach((f) => {
var labelw = new bricks.Text({
cheight: 2,
otext: f.label || f.name,
i18n: true
});
var txtw = new bricks.UiText({
name:f.name,
css: "filler",
value:f.value
});
var cell = new bricks.VBox({
css: "inputbox",
width: "100%",
height: "45%"
});
cell.add_widget(labelw);
cell.add_widget(txtw);
this.add_widget(cell);
this.form.name_inputs[f.name] = txtw;
cell.set_id(f.name);
});
}
create(){
this.dom_element = this._create('form');
}
}
/*
submit_changed: false
fields
submit_url
*/
bricks.FormBase = class extends bricks.Layout {
constructor(opts){
super(opts);
this.name_inputs = {};
this.name_error_widgets = {};
this.field_rules = {};
}
validate_rules(){
var valid = true;
this.clear_all_errors();
for (var name in this.field_rules){
if (!this.field_rules.hasOwnProperty(name)) continue;
var w = this.name_inputs[name];
if (!w) continue;
if (w.parent && w.parent.is_disabled && w.parent.is_disabled()) continue;
var d = w.getValue();
var value = d[name];
var rules = this.field_rules[name];
for (var i = 0; i < rules.length; i++){
var msg = this._check_rule(rules[i], value);
if (msg){
this.show_field_error(name, msg);
if (valid && w.focus) w.focus();
valid = false;
break;
}
}
}
return valid;
}
_check_rule(rule, value){
var v = (value === null || value === undefined) ? '' : String(value);
switch(rule.type){
case 'required':
if (v === '' || v.trim() === '') return rule.message;
break;
case 'minlength':
if (v !== '' && v.length < rule.value) return rule.message;
break;
case 'maxlength':
if (v.length > rule.value) return rule.message;
break;
case 'min':
if (v !== ''){
var n = parseFloat(v);
if (isNaN(n) || n < rule.value) return rule.message;
}
break;
case 'max':
if (v !== ''){
var n = parseFloat(v);
if (isNaN(n) || n > rule.value) return rule.message;
}
break;
case 'pattern':
if (v !== '' && !new RegExp(rule.value).test(v)) return rule.message;
break;
case 'email':
if (v !== '' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) return rule.message;
break;
case 'number':
if (v !== '' && isNaN(parseFloat(v))) return rule.message;
break;
}
return null;
}
show_field_error(name, msg){
var ew = this.name_error_widgets[name];
if (ew){
ew.set_otext(msg);
ew.show();
}
var box = bricks.getWidgetById(name + '_box', this);
if (box && box.dom_element) box.dom_element.classList.add('field-error');
}
clear_all_errors(){
for (var name in this.name_error_widgets){
var ew = this.name_error_widgets[name];
if (ew){ ew.set_otext(''); ew.hide(); }
var box = bricks.getWidgetById(name + '_box', this);
if (box && box.dom_element) box.dom_element.classList.remove('field-error');
}
}
build_toolbar(widget){
var box = new bricks.HBox({height:'auto', width:'100%'});
widget.add_widget(box);
var tools = [
{
icon:bricks_resource('imgs/submit.svg'),
name:'submit',
css:'submit_btn',
label:'Submit'
},
{
icon:bricks_resource('imgs/reset.svg'),
name:'reset',
css:'reset_btn',
label:'Reset'
},
{
icon:bricks_resource('imgs/cancel.svg'),
name:'cancel',
css:'clear_btn',
label:'Cancel'
}
]
var tb_desc={};
var names = [ 'submit', 'reset', 'cancel' ];
if (this.toolbar){
tb_desc = bricks.extend(tb_desc, this.toolbar);
tb_desc.tools = tools;
tools.forEach(t => {
if (! names.includes(t.name)) {
tb_desc.tools.push(t);
}
});
this.toolbar.tools.forEach(t => {
if (! names.includes(t.name)) {
tb_desc.tools.push(t);
}
});
} else {
tb_desc = {
width:"auto",
tools:tools
};
}
var tbw = new bricks.IconTextBar(tb_desc);
tbw.bind('command', this.command_handle.bind(this));
box.add_widget(new bricks.Filler());
box.add_widget(tbw);
box.add_widget(new bricks.Filler());
}
command_handle(event){
var params = event.params;
bricks.debug('Form(): click_handle() params=', params);
if (!params){
error('click_handle() get a null params');
return
}
if (params.name == 'submit'){
this.validation();
} else if (params.name == 'cancel'){
this.cancel();
} else if (params.name == 'reset'){
this.reset_data();
} else {
if (params.action){
var f = bricks.buildEventHandler(this, params);
if (f) f(event);
} else {
this.dispatch(params.name);
}
}
}
cancel(){
this.dispatch('cancel');
}
reset_data(){
for (var name in this.name_inputs){
if (! this.name_inputs.hasOwnProperty(name)){
continue;
}
var w = this.name_inputs[name];
w.reset();
}
}
_getValue(){
var data = {};
for (var name in this.name_inputs){
if (! this.name_inputs.hasOwnProperty(name)){
continue;
}
var w = this.name_inputs[name];
var d = w.getValue();
if (w.required && ( d[name] == '' || d[name] === null)){
bricks.debug('data=', data, 'd=', d);
new bricks.Error({title:'Requirement', message:'required field must input"' + w.label + '"'})
w.focus();
return;
}
bricks.extend(data, d);
}
return data;
}
getValue(){
if (this.data) {
var ret = this.data;
this.data = null;
return ret;
}
return this.get_formdata();
}
toggle_disable(field_name, flg){
var w = bricks.getWidgetById(field_name + '_box', this);
if (! w) return;
w.disabled(flg);
if (flg) w.hide();
else w.show();
if (flg) return;
this.exclusionfields.forEach(arr =>{
if (arr.include(field_name)){
arr.forEach(x => {
if (x!=field_name){
var w1 = bricks.getWidgetById(x + '_box', this);
if (w1) {
w1.disabled(true);
w1.hide();
}
}
});
}
});
}
enable_field(field_name){
this.toggle_disable(field_name, false);
}
disable_field(field_name){
this.toggle_disable(field_name, true);
}
get_formdata(){
var data = new FormData();
var changed = false;
for (var name in this.name_inputs){
if (! this.name_inputs.hasOwnProperty(name)){
continue;
}
var w = this.name_inputs[name];
if (w.parent.is_disabled()) continue;
var d = w.getValue();
if (w.required && ( d[name] == '' || d[name] === null)){
new bricks.Error({title:'Requirement', message:'required field must input"' + w.label + '"'})
w.focus();
return;
}
if (d[name] === null){
continue;
}
if (this.submit_changed){
if (name != 'id' && this.origin_data[name] == d[name]){
continue;
}
}
w.set_formdata(data);
changed = true;
}
this.data = data;
if (changed){
return data;
}
return null
}
async validation(){
if (!this.validate_rules()){
return;
}
var running = new bricks.Running({target:this});
try {
var data;
data = this.get_formdata();
if (! data) {
running.dismiss();
return;
}
// data = bricks.delete_null_values(data);
this.dispatch('submit', data);
if (this.submit_url){
var rc = new bricks.HttpResponse();
var resp = await rc.httpcall(this.submit_url,
{
method:this.method || 'POST',
params:data
});
this.dispatch('submited', resp);
}
} catch (e){
console.log('form submit error', e);
}
running.dismiss();
}
save_origin_data(){
this.origin_data = {};
for (var name in this.name_inputs){
var w = this.name_inputs[name];
var d = w.getValue();
this.origin_data[name] = d[name];
}
}
}
bricks.InlineForm = class extends bricks.HBox {
/*
Horizontal inline form — all fields in one row.
Options:
fields: [{name, label, uitype, placeholder, ...}]
submit_label: "搜索" (button text, default "Submit")
submit_icon: url to icon (optional)
submit_css: "primary" (css class for button)
submit_bgcolor: "#xxx" (background color)
gap: "0.5" (gap between items in charsize)
show_label: true/false (show label before input, default true)
Events:
submit: dispatched with form data on button click
*/
constructor(opts){
opts.width = opts.width || '100%';
opts.height = 'auto';
opts.overflow = 'none';
opts.alignItems = 'center';
opts.gap = opts.gap || '0.5';
super(opts);
this.name_inputs = {};
this.build_fields();
this.build_submit();
}
getValue(){
var data = {};
for (var name in this.name_inputs){
if (!this.name_inputs.hasOwnProperty(name)) continue;
var w = this.name_inputs[name];
var d = w.getValue();
bricks.extend(data, d);
}
return data;
}
setValue(name, value){
var w = this.name_inputs[name];
if (w) w.setValue(value);
}
reset(){
for (var name in this.name_inputs){
if (!this.name_inputs.hasOwnProperty(name)) continue;
this.name_inputs[name].reset();
}
}
validation(){
var data = this.getValue();
this.dispatch('submit', data);
}
build_fields(){
var fields = this.opts.fields || [];
var show_label = this.opts.show_label !== false;
for (var i=0; i<fields.length; i++){
var f = fields[i];
if (f.nonuse) continue;
var cell = new bricks.HBox({
height:'auto',
alignItems:'center',
gap:'0.3'
});
cell.set_css('inline-form-field');
if (show_label && f.label){
var lbl = new bricks.Text({
otext: f.label,
height:'auto',
dynsize:true,
i18n:true
});
cell.add_widget(lbl);
}
var inputdesc = objcopy(f);
if (!inputdesc.width) inputdesc.width = 'auto';
if (f.placeholder && !inputdesc.placeholder){
inputdesc.placeholder = f.placeholder;
} else if (!inputdesc.placeholder && f.label){
inputdesc.placeholder = f.label;
}
if (f.codes && !inputdesc.data) inputdesc.data = f.codes;
var w = Input.factory(inputdesc);
if (w){
cell.add_widget(w);
this.name_inputs[f.name] = w;
w.set_id(f.name);
}
cell.set_id(f.name + '_box');
this.add_widget(cell);
}
}
build_submit(){
this.add_widget(new bricks.Filler());
var btnopts = {
label: this.opts.submit_label || 'Submit',
css: this.opts.submit_css || 'primary'
};
if (this.opts.submit_icon) btnopts.icon = this.opts.submit_icon;
if (this.opts.submit_bgcolor) btnopts.bgcolor = this.opts.submit_bgcolor;
var btn = new bricks.Button(btnopts);
btn.bind('click', this.validation.bind(this));
this.add_widget(btn);
}
}
bricks.Form = class extends bricks.FormBase {
/*
{
title:
description:
notoolbar:False,
input_layout:"VBox" or "HBox", default is "VBox",
cols:
dataurl:
toolbar:
submit_url:
method:
exclussionfields:[
[a,b,c],
[x,y]
]
fields
}
field {
name:
label:
uitype:
nonuse: # 不使用
...
}
*/
constructor(opts){
opts.height = "100%";
opts.width = "100%";
opts.overflow = "auto";
super(opts);
this.need_formdata = false;
if (this.opts.title){
var t = new bricks.Title3({
otext:this.opts.title,
height:'auto',
i18n:true});
this.add_widget(t, 0);
}
if (this.opts.description){
var d = new bricks.Text({
otext:this.opts.description,
height:'auto',
i18n:true});
this.add_widget(d);
}
this.set_css('vcontainer');
var filler = new bricks.Filler({});
this.add_widget(filler);
this.nontextfields = [];
this.textfields = [];
this.fields.forEach((f) => {
if (f.uitype == 'text'){
this.textfields.push(f);
} else {
this.nontextfields.push(f);
}
});
this.body = new bricks.FormBody(this, opts);
filler.add_widget(this.body);
if (! opts.notoolbar)
this.build_toolbar(this);
this.save_origin_data();
}
}
bricks.Factory.register('InlineForm', bricks.InlineForm);
bricks.Factory.register('Form', bricks.Form);