build FormData from optional form and optional submitter

This commit is contained in:
Karl Seguin
2025-12-15 12:31:30 +08:00
parent 4bebc4c142
commit 9b3107d4fe
8 changed files with 357 additions and 112 deletions

View File

@@ -349,7 +349,7 @@
testing.expectEqual([['b', '3']], acc);
</script>
<!-- <script id=serialize>
<script id=serialize>
{
let form1 = $('#form1');
let submit1 = $('#s1');
@@ -380,4 +380,249 @@
testing.expectEqual(['mlt-2', 'tea'], acc[12]);
testing.expectEqual(['s1', 's1-v'], acc[13]);
}
</script> -->
</script>
<script id=submitterBehavior>
{
// Test that only the specified submitter is included in FormData
const form = document.createElement('form');
const input1 = document.createElement('input');
input1.name = 'field1';
input1.value = 'value1';
form.appendChild(input1);
const submit1 = document.createElement('input');
submit1.type = 'submit';
submit1.name = 'action';
submit1.value = 'save';
form.appendChild(submit1);
const submit2 = document.createElement('input');
submit2.type = 'submit';
submit2.name = 'action';
submit2.value = 'delete';
form.appendChild(submit2);
const submit3 = document.createElement('input');
submit3.type = 'submit';
submit3.name = 'action';
submit3.value = 'cancel';
form.appendChild(submit3);
// FormData with submit2 as submitter - should only include submit2
const fd = new FormData(form, submit2);
testing.expectEqual('value1', fd.get('field1'));
testing.expectEqual('delete', fd.get('action'));
testing.expectEqual(['delete'], fd.getAll('action'));
// FormData with no submitter - should not include any submit buttons
const fd2 = new FormData(form);
testing.expectEqual('value1', fd2.get('field1'));
testing.expectEqual(null, fd2.get('action'));
testing.expectEqual([], fd2.getAll('action'));
// FormData with submit1 as submitter
const fd3 = new FormData(form, submit1);
testing.expectEqual('value1', fd3.get('field1'));
testing.expectEqual('save', fd3.get('action'));
testing.expectEqual(['save'], fd3.getAll('action'));
}
</script>
<script id=imageSubmitter>
{
// Test that image inputs add name.x and name.y coordinates
const form = document.createElement('form');
const input1 = document.createElement('input');
input1.name = 'username';
input1.value = 'alice';
form.appendChild(input1);
const image1 = document.createElement('input');
image1.type = 'image';
image1.name = 'submit';
image1.value = 'ignored'; // value is ignored for image inputs
form.appendChild(image1);
const fd = new FormData(form, image1);
testing.expectEqual('alice', fd.get('username'));
testing.expectEqual('0', fd.get('submit.x'));
testing.expectEqual('0', fd.get('submit.y'));
testing.expectEqual(null, fd.get('submit')); // name without .x/.y should not exist
// Verify order and that .x comes before .y
const entries = Array.from(fd.entries());
testing.expectEqual([['username', 'alice'], ['submit.x', '0'], ['submit.y', '0']], entries);
}
</script>
<script id=multipleImagesWithSameName>
{
// Test that when multiple images share a name, only submitter's coordinates are included
const form = document.createElement('form');
const img1 = document.createElement('input');
img1.type = 'image';
img1.name = 'coords';
form.appendChild(img1);
const img2 = document.createElement('input');
img2.type = 'image';
img2.name = 'coords';
form.appendChild(img2);
const img3 = document.createElement('input');
img3.type = 'image';
img3.name = 'coords';
form.appendChild(img3);
// Only img2 should be included
const fd = new FormData(form, img2);
testing.expectEqual(['0'], fd.getAll('coords.x'));
testing.expectEqual(['0'], fd.getAll('coords.y'));
const entries = Array.from(fd.entries());
testing.expectEqual([['coords.x', '0'], ['coords.y', '0']], entries);
}
</script>
<script id=buttonSubmitter>
{
// Test that <button> elements work as submitters
const form = document.createElement('form');
const input1 = document.createElement('input');
input1.name = 'data';
input1.value = 'test';
form.appendChild(input1);
const button1 = document.createElement('button');
button1.name = 'btn';
button1.value = 'first';
button1.type = 'submit';
form.appendChild(button1);
const button2 = document.createElement('button');
button2.name = 'btn';
button2.value = 'second';
button2.type = 'submit';
form.appendChild(button2);
// With button1 as submitter
const fd1 = new FormData(form, button1);
testing.expectEqual('test', fd1.get('data'));
testing.expectEqual('first', fd1.get('btn'));
testing.expectEqual(['first'], fd1.getAll('btn'));
// With button2 as submitter
const fd2 = new FormData(form, button2);
testing.expectEqual('test', fd2.get('data'));
testing.expectEqual('second', fd2.get('btn'));
testing.expectEqual(['second'], fd2.getAll('btn'));
// No submitter - no button included
const fd3 = new FormData(form);
testing.expectEqual('test', fd3.get('data'));
testing.expectEqual(null, fd3.get('btn'));
}
</script>
<script id=mixedSubmitTypes>
{
// Test mix of input[type=submit], input[type=image], and button
const form = document.createElement('form');
const field = document.createElement('input');
field.name = 'name';
field.value = 'Bob';
form.appendChild(field);
const submit = document.createElement('input');
submit.type = 'submit';
submit.name = 'op';
submit.value = 'save';
form.appendChild(submit);
const image = document.createElement('input');
image.type = 'image';
image.name = 'map';
form.appendChild(image);
const button = document.createElement('button');
button.type = 'submit';
button.name = 'op';
button.value = 'delete';
form.appendChild(button);
// Using image as submitter - only image coordinates included
const fd1 = new FormData(form, image);
testing.expectEqual('Bob', fd1.get('name'));
testing.expectEqual('0', fd1.get('map.x'));
testing.expectEqual('0', fd1.get('map.y'));
testing.expectEqual(null, fd1.get('op'));
// Using submit as submitter
const fd2 = new FormData(form, submit);
testing.expectEqual('Bob', fd2.get('name'));
testing.expectEqual('save', fd2.get('op'));
testing.expectEqual(null, fd2.get('map.x'));
testing.expectEqual(null, fd2.get('map.y'));
// Using button as submitter
const fd3 = new FormData(form, button);
testing.expectEqual('Bob', fd3.get('name'));
testing.expectEqual('delete', fd3.get('op'));
testing.expectEqual(null, fd3.get('map.x'));
}
</script>
<script id=submitterWithoutName>
{
// Test that submitter without name is not included
const form = document.createElement('form');
const input = document.createElement('input');
input.name = 'field';
input.value = 'data';
form.appendChild(input);
const submit = document.createElement('input');
submit.type = 'submit';
// no name attribute
submit.value = 'Submit';
form.appendChild(submit);
const fd = new FormData(form, submit);
testing.expectEqual('data', fd.get('field'));
// Should not have any submit-related entries
const entries = Array.from(fd.entries());
testing.expectEqual([['field', 'data']], entries);
}
</script>
<script id=imageWithoutName>
{
// Test that image input without name still submits x and y coordinates
const form = document.createElement('form');
const input = document.createElement('input');
input.name = 'field';
input.value = 'data';
form.appendChild(input);
const image = document.createElement('input');
image.type = 'image';
// no name attribute
form.appendChild(image);
const fd = new FormData(form, image);
testing.expectEqual('data', fd.get('field'));
// Image without name submits plain 'x' and 'y' keys
testing.expectEqual('0', fd.get('x'));
testing.expectEqual('0', fd.get('y'));
const entries = Array.from(fd.entries());
testing.expectEqual([['field', 'data'], ['x', '0'], ['y', '0']], entries);
}
</script>

View File

@@ -202,7 +202,7 @@ pub fn getTagNameLower(self: *const Element) []const u8 {
.slot => "slot",
.style => "style",
.template => "template",
.text_area => "textarea",
.textarea => "textarea",
.title => "title",
.ul => "ul",
.unknown => |e| e._tag_name.str(),
@@ -254,7 +254,7 @@ pub fn getTagNameSpec(self: *const Element, buf: []u8) []const u8 {
.slot => "SLOT",
.style => "STYLE",
.template => "TEMPLATE",
.text_area => "TEXTAREA",
.textarea => "TEXTAREA",
.title => "TITLE",
.ul => "UL",
.unknown => |e| switch (self._namespace) {
@@ -1097,7 +1097,7 @@ pub fn getTag(self: *const Element) Tag {
.slot => .slot,
.option => .option,
.template => .template,
.text_area => .textarea,
.textarea => .textarea,
.input => .input,
.link => .link,
.meta => .meta,

View File

@@ -194,6 +194,10 @@ pub fn NodeLive(comptime mode: Mode) type {
return null;
}
pub fn next(self: *Self) ?*Element {
return self.nextTw(&self._tw);
}
pub fn nextTw(self: *Self, tw: *TW) ?*Element {
while (tw.next()) |node| {
if (self.matches(node)) {
@@ -297,7 +301,7 @@ pub fn NodeLive(comptime mode: Mode) type {
if (el._type != .html) return false;
const html = el._type.html;
return switch (html._type) {
.input, .button, .select, .text_area => true,
.input, .button, .select, .textarea => true,
else => false,
};
}

View File

@@ -100,7 +100,7 @@ pub const Type = union(enum) {
slot: *Slot,
style: *Style,
template: *Template,
text_area: *TextArea,
textarea: *TextArea,
title: *Title,
ul: *UL,
unknown: *Unknown,
@@ -156,7 +156,7 @@ pub fn className(self: *const HtmlElement) []const u8 {
.slot => "[object HTMLSlotElement]",
.style => "[object HTMLSyleElement]",
.template => "[object HTMLTemplateElement]",
.text_area => "[object HTMLTextAreaElement]",
.textarea => "[object HTMLTextAreaElement]",
.title => "[object HTMLTitleElement]",
.ul => "[object HTMLULElement]",
.unknown => "[object HTMLUnknownElement]",

View File

@@ -58,6 +58,14 @@ pub fn setName(self: *Button, name: []const u8, page: *Page) !void {
try self.asElement().setAttributeSafe("name", name, page);
}
pub fn getValue(self: *const Button) []const u8 {
return self.asConstElement().getAttributeSafe("value") orelse "";
}
pub fn setValue(self: *Button, value: []const u8, page: *Page) !void {
try self.asElement().setAttributeSafe("value", value, page);
}
pub fn getRequired(self: *const Button) bool {
return self.asConstElement().getAttributeSafe("required") != null;
}
@@ -107,6 +115,7 @@ pub const JsApi = struct {
pub const name = bridge.accessor(Button.getName, Button.setName, .{});
pub const required = bridge.accessor(Button.getRequired, Button.setRequired, .{});
pub const form = bridge.accessor(Button.getForm, null, .{});
pub const value = bridge.accessor(Button.getValue, Button.setValue, .{});
};
pub const Build = struct {

View File

@@ -25,10 +25,10 @@ const HtmlElement = @import("../Html.zig");
const TreeWalker = @import("../../TreeWalker.zig");
const collections = @import("../../collections.zig");
const Input = @import("Input.zig");
const Button = @import("Button.zig");
const Select = @import("Select.zig");
const TextArea = @import("TextArea.zig");
pub const Input = @import("Input.zig");
pub const Button = @import("Button.zig");
pub const Select = @import("Select.zig");
pub const TextArea = @import("TextArea.zig");
const Form = @This();
_proto: *HtmlElement,

View File

@@ -24,7 +24,7 @@ const Element = @import("../../Element.zig");
const HtmlElement = @import("../Html.zig");
const collections = @import("../../collections.zig");
const Form = @import("Form.zig");
const Option = @import("Option.zig");
pub const Option = @import("Option.zig");
const Select = @This();
@@ -50,12 +50,16 @@ pub fn getValue(self: *Select, page: *Page) []const u8 {
var iter = self.asNode().childrenIterator();
while (iter.next()) |child| {
const option = child.is(Option) orelse continue;
if (first_option == null) {
first_option = option;
if (option.getDisabled()) {
continue;
}
if (option.getSelected()) {
return option.getValue(page);
}
if (first_option == null) {
first_option = option;
}
}
// No explicitly selected option, return first option's value
if (first_option) |opt| {

View File

@@ -26,19 +26,17 @@ const Form = @import("../element/html/Form.zig");
const Element = @import("../Element.zig");
const KeyValueList = @import("../KeyValueList.zig");
const Alloctor = std.mem.Allocator;
const Allocator = std.mem.Allocator;
const FormData = @This();
_arena: Alloctor,
_arena: Allocator,
_list: KeyValueList,
pub fn init(form_: ?*Form, submitter_: ?*Element, page: *Page) !*FormData {
_ = form_;
_ = submitter_;
pub fn init(form: ?*Form, submitter: ?*Element, page: *Page) !*FormData {
return page._factory.create(FormData{
._arena = page.arena,
._list = KeyValueList.init(),
._list = try collectForm(page.arena, form, submitter, page),
});
}
@@ -108,6 +106,82 @@ pub const Iterator = struct {
}
};
fn collectForm(arena: Allocator, form_: ?*Form, submitter_: ?*Element, page: *Page) !KeyValueList {
var list: KeyValueList = .empty;
const form = form_ orelse return list;
var elements = try form.getElements(page);
var it = try elements.iterator();
while (it.next()) |element| {
if (element.getAttributeSafe("disabled") != null) {
continue;
}
// Handle image submitters first - they can submit without a name
if (element.is(Form.Input)) |input| {
if (input._input_type == .image) {
const submitter = submitter_ orelse continue;
if (submitter != element) {
continue;
}
const name = element.getAttributeSafe("name");
const x_key = if (name) |n| try std.fmt.allocPrint(arena, "{s}.x", .{n}) else "x";
const y_key = if (name) |n| try std.fmt.allocPrint(arena, "{s}.y", .{n}) else "y";
try list.append(arena, x_key, "0");
try list.append(arena, y_key, "0");
continue;
}
}
const name = element.getAttributeSafe("name") orelse continue;
const value = blk: {
if (element.is(Form.Input)) |input| {
const input_type = input._input_type;
if (input_type == .checkbox or input_type == .radio) {
if (!input.getChecked()) {
continue;
}
}
if (input_type == .submit) {
const submitter = submitter_ orelse continue;
if (submitter != element) {
continue;
}
}
break :blk input.getValue();
}
if (element.is(Form.Select)) |select| {
if (select.getMultiple() == false) {
break :blk select.getValue(page);
}
var options = try select.getSelectedOptions(page);
while (options.next()) |option| {
try list.append(arena, name, option.as(Form.Select.Option).getValue(page));
}
continue;
}
if (element.is(Form.TextArea)) |textarea| {
break :blk textarea.getValue();
}
if (submitter_) |submitter| {
if (submitter == element) {
// The form iterator only yields form controls. If we're here
// all other control types have been handled. So the cast is safe.
break :blk element.as(Form.Button).getValue();
}
}
continue;
};
try list.append(arena, name, value);
}
return list;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(FormData);
@@ -131,97 +205,6 @@ pub const JsApi = struct {
pub const forEach = bridge.function(FormData.forEach, .{});
};
// fn collectForm(form: *Form, submitter_: ?*Element, page: *Page) !KeyValueList {
// const arena = page.arena;
// // Don't use libdom's formGetCollection (aka dom_html_form_element_get_elements)
// // It doesn't work with dynamically added elements, because their form
// // property doesn't get set. We should fix that.
// // However, even once fixed, there are other form-collection features we
// // probably want to implement (like disabled fieldsets), so we might want
// // to stick with our own walker even if fix libdom to properly support
// // dynamically added elements.
// const node_list = try @import("../dom/css.zig").querySelectorAll(arena, @ptrCast(@alignCast(form)), "input,select,button,textarea");
// const nodes = node_list.nodes.items;
// var entries: kv.List = .{};
// try entries.ensureTotalCapacity(arena, nodes.len);
// var submitter_included = false;
// const submitter_name_ = try getSubmitterName(submitter_);
// for (nodes) |node| {
// const element = parser.nodeToElement(node);
// // must have a name
// const name = try parser.elementGetAttribute(element, "name") orelse continue;
// if (try parser.elementGetAttribute(element, "disabled") != null) {
// continue;
// }
// const tag = try parser.elementTag(element);
// switch (tag) {
// .input => {
// const tpe = try parser.inputGetType(@ptrCast(element));
// if (std.ascii.eqlIgnoreCase(tpe, "image")) {
// if (submitter_name_) |submitter_name| {
// if (std.mem.eql(u8, submitter_name, name)) {
// const key_x = try std.fmt.allocPrint(arena, "{s}.x", .{name});
// const key_y = try std.fmt.allocPrint(arena, "{s}.y", .{name});
// try entries.appendOwned(arena, key_x, "0");
// try entries.appendOwned(arena, key_y, "0");
// submitter_included = true;
// }
// }
// continue;
// }
// if (std.ascii.eqlIgnoreCase(tpe, "checkbox") or std.ascii.eqlIgnoreCase(tpe, "radio")) {
// if (try parser.inputGetChecked(@ptrCast(element)) == false) {
// continue;
// }
// }
// if (std.ascii.eqlIgnoreCase(tpe, "submit")) {
// if (submitter_name_ == null or !std.mem.eql(u8, submitter_name_.?, name)) {
// continue;
// }
// submitter_included = true;
// }
// const value = try parser.inputGetValue(@ptrCast(element));
// try entries.appendOwned(arena, name, value);
// },
// .select => {
// const select: *parser.Select = @ptrCast(node);
// try collectSelectValues(arena, select, name, &entries, page);
// },
// .textarea => {
// const textarea: *parser.TextArea = @ptrCast(node);
// const value = try parser.textareaGetValue(textarea);
// try entries.appendOwned(arena, name, value);
// },
// .button => if (submitter_name_) |submitter_name| {
// if (std.mem.eql(u8, submitter_name, name)) {
// const value = (try parser.elementGetAttribute(element, "value")) orelse "";
// try entries.appendOwned(arena, name, value);
// submitter_included = true;
// }
// },
// else => unreachable,
// }
// }
// if (submitter_included == false) {
// if (submitter_name_) |submitter_name| {
// // this can happen if the submitter is outside the form, but associated
// // with the form via a form=ID attribute
// const value = (try parser.elementGetAttribute(@ptrCast(submitter_.?), "value")) orelse "";
// try entries.appendOwned(arena, submitter_name, value);
// }
// }
// return entries;
// }
const testing = @import("../../../testing.zig");
test "WebApi: FormData" {
try testing.htmlRunner("net/form_data.html", .{});