@opentf/web-form 0.1.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/README.md +222 -0
- package/bunfig.toml +6 -0
- package/index.js +317 -130
- package/package.json +12 -5
- package/tests/Basic.test.js +37 -0
- package/tests/BasicForm.jsx +29 -0
- package/tests/Component.test.js +46 -0
- package/tests/DynamicArrayForm.jsx +32 -0
- package/tests/DynamicArrays.test.js +33 -0
- package/tests/DynamicListForm.jsx +20 -0
- package/tests/Logic.test.js +153 -0
- package/tests/NestedFields.test.js +28 -0
- package/tests/NestedForm.jsx +32 -0
- package/tests/ProfileForm.jsx +34 -0
- package/tests/Reactivity.test.js +26 -0
- package/tests/RegisterMetadata.test.js +83 -0
- package/tests/State.test.js +93 -0
- package/tests/StateComponent.test.js +58 -0
- package/tests/StateForm.jsx +29 -0
- package/tests/ValidationModes.test.js +84 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { expect, test, describe } from "bun:test";
|
|
2
|
+
import { render, userEvent } from "@opentf/web-test";
|
|
3
|
+
import DynamicArrayForm from "./DynamicArrayForm.jsx";
|
|
4
|
+
|
|
5
|
+
describe("Dynamic Arrays", () => {
|
|
6
|
+
test("adds and removes items", async () => {
|
|
7
|
+
const { getByTestId, getAllByRole, queryByTestId } = render(DynamicArrayForm);
|
|
8
|
+
const user = userEvent.setup();
|
|
9
|
+
|
|
10
|
+
const addBtn = getByTestId("add-item");
|
|
11
|
+
const list = getByTestId("item-list");
|
|
12
|
+
|
|
13
|
+
expect(getAllByRole("listitem").length).toBe(1);
|
|
14
|
+
expect(getByTestId("item-0").textContent).toContain("Item 1");
|
|
15
|
+
|
|
16
|
+
// Add item
|
|
17
|
+
await user.click(addBtn);
|
|
18
|
+
expect(getAllByRole("listitem").length).toBe(2);
|
|
19
|
+
expect(getByTestId("item-1").textContent).toContain("Item 2");
|
|
20
|
+
|
|
21
|
+
// Add another
|
|
22
|
+
await user.click(addBtn);
|
|
23
|
+
expect(getAllByRole("listitem").length).toBe(3);
|
|
24
|
+
|
|
25
|
+
// Remove middle item (Item 2 at index 1)
|
|
26
|
+
await user.click(getByTestId("remove-1"));
|
|
27
|
+
expect(getAllByRole("listitem").length).toBe(2);
|
|
28
|
+
|
|
29
|
+
// Check remaining items
|
|
30
|
+
expect(getByTestId("item-0").textContent).toContain("Item 1");
|
|
31
|
+
expect(getByTestId("item-1").textContent).toContain("Item 3"); // Item 3 moved to index 1
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createForm } from "../index.js";
|
|
2
|
+
|
|
3
|
+
export default function DynamicPrimitiveForm() {
|
|
4
|
+
const form = createForm({
|
|
5
|
+
initialValues: { skills: ["JavaScript", "HTML"] }
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<div>
|
|
10
|
+
{form.values.skills.map((skill, index) => (
|
|
11
|
+
<div data-testid={`row-${index}`}>
|
|
12
|
+
<input
|
|
13
|
+
{...form.register(`skills.${index}`)}
|
|
14
|
+
data-testid={`input-${index}`}
|
|
15
|
+
/>
|
|
16
|
+
</div>
|
|
17
|
+
))}
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { expect, test, describe } from "bun:test";
|
|
2
|
+
import { createForm } from "../index.js";
|
|
3
|
+
|
|
4
|
+
describe("Web App Framework Forms Library - Pure Logic", () => {
|
|
5
|
+
test("initializes with flat values", () => {
|
|
6
|
+
const form = createForm({
|
|
7
|
+
initialValues: { username: "alice", email: "alice@example.com" }
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
expect(form.values.username).toBe("alice");
|
|
11
|
+
expect(form.values.email).toBe("alice@example.com");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("initializes with nested values", () => {
|
|
15
|
+
const form = createForm({
|
|
16
|
+
initialValues: {
|
|
17
|
+
profile: { firstName: "John", lastName: "Doe" }
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
expect(form.values.profile.firstName).toBe("John");
|
|
22
|
+
expect(form.values.profile.lastName).toBe("Doe");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("initializes with array values", () => {
|
|
26
|
+
const form = createForm({
|
|
27
|
+
initialValues: {
|
|
28
|
+
skills: ["JS", "Framework"]
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(form.values.skills[0]).toBe("JS");
|
|
33
|
+
expect(form.values.skills[1]).toBe("Framework");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("updates values reactively", () => {
|
|
37
|
+
const form = createForm({
|
|
38
|
+
initialValues: { count: 0 }
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
form.values.count = 1;
|
|
42
|
+
expect(form.values.count).toBe(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("supports direct assignment to values proxy", () => {
|
|
46
|
+
const form = createForm({
|
|
47
|
+
initialValues: { user: { name: "A" } }
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
form.values.user = { name: "B" };
|
|
51
|
+
expect(form.values.user.name).toBe("B");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("validates values", () => {
|
|
55
|
+
const form = createForm({
|
|
56
|
+
mode: "onChange",
|
|
57
|
+
initialValues: { age: 20 },
|
|
58
|
+
validate: (v) => {
|
|
59
|
+
const errors = {};
|
|
60
|
+
if (v.age < 18) errors.age = "Too young";
|
|
61
|
+
return errors;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(form.errors.age).toBeUndefined();
|
|
66
|
+
form.values.age = 10;
|
|
67
|
+
expect(form.errors.age).toBe("Too young");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("errors proxy supports direct access", () => {
|
|
71
|
+
const form = createForm({
|
|
72
|
+
mode: "onChange",
|
|
73
|
+
initialValues: { username: "" },
|
|
74
|
+
validate: (v) => (v.username ? {} : { username: "Required" }),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(form.errors.username).toBe("Required");
|
|
78
|
+
|
|
79
|
+
form.values.username = "Alice";
|
|
80
|
+
expect(form.errors.username).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
test("supports Zod-style validator returning { errors }", () => {
|
|
85
|
+
const form = createForm({
|
|
86
|
+
mode: "onChange",
|
|
87
|
+
initialValues: { email: "a" },
|
|
88
|
+
validator: (v) => {
|
|
89
|
+
return { errors: { email: "Invalid email" } };
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(form.errors.email).toBe("Invalid email");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("handles async validation", async () => {
|
|
97
|
+
const form = createForm({
|
|
98
|
+
mode: "onChange",
|
|
99
|
+
initialValues: { username: "abc" },
|
|
100
|
+
validate: async (v) => {
|
|
101
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
102
|
+
return v.username.length < 3 ? { username: "Too short" } : {};
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(form.isValidating).toBe(true); // Immediate validation in onChange mode
|
|
107
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
108
|
+
expect(form.isValidating).toBe(false);
|
|
109
|
+
expect(form.errors.username).toBeUndefined();
|
|
110
|
+
|
|
111
|
+
form.values.username = "al";
|
|
112
|
+
expect(form.isValidating).toBe(true);
|
|
113
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
114
|
+
expect(form.isValidating).toBe(false);
|
|
115
|
+
expect(form.errors.username).toBe("Too short");
|
|
116
|
+
|
|
117
|
+
form.values.username = "alice";
|
|
118
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
119
|
+
expect(form.errors.username).toBeUndefined();
|
|
120
|
+
expect(form.isValid).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("reactive array mutations (push/splice)", () => {
|
|
124
|
+
const form = createForm({
|
|
125
|
+
initialValues: { tags: ["js"] },
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
let changeCount = 0;
|
|
129
|
+
form._signals.values.subscribe(() => changeCount++);
|
|
130
|
+
expect(changeCount).toBe(1);
|
|
131
|
+
|
|
132
|
+
form.values.tags.push("web");
|
|
133
|
+
expect(form.values.tags).toEqual(["js", "web"]);
|
|
134
|
+
expect(changeCount).toBe(2);
|
|
135
|
+
|
|
136
|
+
form.values.tags.splice(0, 1);
|
|
137
|
+
expect(form.values.tags).toEqual(["web"]);
|
|
138
|
+
expect(changeCount).toBe(3);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("reset() with custom values", () => {
|
|
142
|
+
const form = createForm({
|
|
143
|
+
initialValues: { a: 1 },
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
form.values.a = 2;
|
|
147
|
+
form.reset({ a: 10 });
|
|
148
|
+
expect(form.values.a).toBe(10);
|
|
149
|
+
expect(form.isChanged).toBe(false); // isChanged is against the NEW initialValues?
|
|
150
|
+
// Actually, createForm stores initialValues from the constructor.
|
|
151
|
+
// If reset(newVals) is called, it should ideally update the reference for isChanged.
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { expect, test, describe } from "bun:test";
|
|
2
|
+
import { render, userEvent } from "@opentf/web-test";
|
|
3
|
+
import NestedForm from "./NestedForm.jsx";
|
|
4
|
+
|
|
5
|
+
describe("Nested Fields", () => {
|
|
6
|
+
test("handles deep object paths", async () => {
|
|
7
|
+
let submittedValues = null;
|
|
8
|
+
const { getByTestId } = render(NestedForm, {
|
|
9
|
+
onSubmit: (v) => submittedValues = v
|
|
10
|
+
});
|
|
11
|
+
const user = userEvent.setup();
|
|
12
|
+
|
|
13
|
+
const firstName = getByTestId("first-name");
|
|
14
|
+
const notifications = getByTestId("notifications");
|
|
15
|
+
const submit = getByTestId("submit");
|
|
16
|
+
|
|
17
|
+
expect(firstName.value).toBe("John");
|
|
18
|
+
expect(notifications.checked).toBe(true);
|
|
19
|
+
|
|
20
|
+
await user.clear(firstName);
|
|
21
|
+
await user.type(firstName, "Jane");
|
|
22
|
+
await user.click(notifications); // Uncheck
|
|
23
|
+
|
|
24
|
+
await user.click(submit);
|
|
25
|
+
expect(submittedValues.user.profile.firstName).toBe("Jane");
|
|
26
|
+
expect(submittedValues.user.settings.notifications).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createForm } from "../index.js";
|
|
2
|
+
|
|
3
|
+
export default function NestedForm({ onSubmit }) {
|
|
4
|
+
const form = createForm({
|
|
5
|
+
initialValues: {
|
|
6
|
+
user: {
|
|
7
|
+
profile: { firstName: "John" },
|
|
8
|
+
settings: { notifications: true }
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div>
|
|
15
|
+
<input
|
|
16
|
+
{...form.register("user.profile.firstName")}
|
|
17
|
+
data-testid="first-name"
|
|
18
|
+
/>
|
|
19
|
+
<input
|
|
20
|
+
type="checkbox"
|
|
21
|
+
{...form.register("user.settings.notifications")}
|
|
22
|
+
data-testid="notifications"
|
|
23
|
+
/>
|
|
24
|
+
<button
|
|
25
|
+
onclick={form.handleSubmit(onSubmit)}
|
|
26
|
+
data-testid="submit"
|
|
27
|
+
>
|
|
28
|
+
Submit
|
|
29
|
+
</button>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createForm } from "../index.js";
|
|
2
|
+
|
|
3
|
+
export default function ProfileForm() {
|
|
4
|
+
const form = createForm({
|
|
5
|
+
initialValues: {
|
|
6
|
+
user: { name: "John" },
|
|
7
|
+
tags: ["js"]
|
|
8
|
+
},
|
|
9
|
+
validate: (v) => {
|
|
10
|
+
const errors = {};
|
|
11
|
+
if (v.user.name.length < 3) errors.user = { name: "Too short" };
|
|
12
|
+
return errors;
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const addTag = () => form.values.tags.push(`tag-${form.values.tags.length}`);
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div>
|
|
20
|
+
<h1 data-testid="title">{form.values.user.name}</h1>
|
|
21
|
+
<input {...form.register("user.name")} data-testid="name-input" />
|
|
22
|
+
<div data-testid="name-error">{form.errors.user?.name}</div>
|
|
23
|
+
|
|
24
|
+
<ul data-testid="tag-list">
|
|
25
|
+
{form.values.tags.map((tag, i) => (
|
|
26
|
+
<li data-testid={`tag-${i}`}>{tag}</li>
|
|
27
|
+
))}
|
|
28
|
+
</ul>
|
|
29
|
+
<button onclick={addTag} data-testid="add-tag">Add Tag</button>
|
|
30
|
+
|
|
31
|
+
<div data-testid="status">{form.isValid ? "Valid" : "Invalid"}</div>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { expect, test, describe } from "bun:test";
|
|
2
|
+
import { render, userEvent } from "@opentf/web-test";
|
|
3
|
+
import DynamicPrimitiveForm from "./DynamicListForm.jsx";
|
|
4
|
+
|
|
5
|
+
describe("Web Form Capabilities", () => {
|
|
6
|
+
test("reactively updates dynamic primitive arrays without losing focus", async () => {
|
|
7
|
+
const { getByTestId } = render(DynamicPrimitiveForm);
|
|
8
|
+
const user = userEvent.setup();
|
|
9
|
+
|
|
10
|
+
const input0 = getByTestId("input-0");
|
|
11
|
+
const input1 = getByTestId("input-1");
|
|
12
|
+
|
|
13
|
+
expect(input0.value).toBe("JavaScript");
|
|
14
|
+
expect(input1.value).toBe("HTML");
|
|
15
|
+
|
|
16
|
+
// Simulate typing a new character into the first input
|
|
17
|
+
await user.type(input0, "!");
|
|
18
|
+
|
|
19
|
+
// The value should update immediately via the form's reactivity loop
|
|
20
|
+
expect(input0.value).toBe("JavaScript!");
|
|
21
|
+
|
|
22
|
+
// Crucially, the exact same DOM node should be retained (focus proves this).
|
|
23
|
+
// The node must not be destroyed and recreated.
|
|
24
|
+
expect(document.activeElement).toBe(input0);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { createForm } from "../index.js";
|
|
3
|
+
import { effect, signal, batch } from "@opentf/web";
|
|
4
|
+
|
|
5
|
+
test("register() returns reactive metadata", async () => {
|
|
6
|
+
const form = createForm({
|
|
7
|
+
initialValues: { username: "" },
|
|
8
|
+
validator: (v) => {
|
|
9
|
+
const errors = {};
|
|
10
|
+
if (!v.username) errors.username = "Required";
|
|
11
|
+
return { errors };
|
|
12
|
+
},
|
|
13
|
+
mode: "onChange"
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const field = form.register("username");
|
|
17
|
+
|
|
18
|
+
expect(field.name).toBe("username");
|
|
19
|
+
expect(field.error.value).toBe("Required"); // Validated on init due to onChange mode
|
|
20
|
+
expect(field.isTouched.value).toBe(false);
|
|
21
|
+
|
|
22
|
+
// Update value to clear error
|
|
23
|
+
form.values.username = "valid";
|
|
24
|
+
expect(field.error.value).toBeUndefined();
|
|
25
|
+
|
|
26
|
+
// Trigger touch
|
|
27
|
+
field.onblur();
|
|
28
|
+
expect(field.isTouched.value).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("register() handles nested metadata", () => {
|
|
32
|
+
const form = createForm({
|
|
33
|
+
initialValues: { profile: { firstName: "" } },
|
|
34
|
+
validator: (v) => {
|
|
35
|
+
const errors = {};
|
|
36
|
+
if (!v.profile?.firstName) errors.profile = { firstName: "Required" };
|
|
37
|
+
return { errors };
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const field = form.register("profile.firstName");
|
|
42
|
+
|
|
43
|
+
expect(field.name).toBe("profile.firstName");
|
|
44
|
+
expect(field.isTouched.value).toBe(false);
|
|
45
|
+
|
|
46
|
+
// Manual touch
|
|
47
|
+
field.onblur();
|
|
48
|
+
expect(field.isTouched.value).toBe(true);
|
|
49
|
+
|
|
50
|
+
// Manual error update
|
|
51
|
+
form._signals.errors.value = { profile: { firstName: "Too short" } };
|
|
52
|
+
form._notifySignals("e"); // Manual notify
|
|
53
|
+
expect(field.error.value).toBe("Too short");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("register() metadata is reactive in effects", () => {
|
|
57
|
+
const form = createForm({
|
|
58
|
+
initialValues: { email: "" }
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const field = form.register("email");
|
|
62
|
+
let caughtError = null;
|
|
63
|
+
let caughtTouched = null;
|
|
64
|
+
|
|
65
|
+
effect(() => {
|
|
66
|
+
caughtError = field.error.value;
|
|
67
|
+
caughtTouched = field.isTouched.value;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(caughtError).toBeUndefined();
|
|
71
|
+
expect(caughtTouched).toBe(false);
|
|
72
|
+
|
|
73
|
+
// Update state
|
|
74
|
+
batch(() => {
|
|
75
|
+
form._signals.errors.value = { email: "Invalid" };
|
|
76
|
+
form._signals.touched.value = { email: true };
|
|
77
|
+
form._notifySignals("e");
|
|
78
|
+
form._notifySignals("t");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(caughtError).toBe("Invalid");
|
|
82
|
+
expect(caughtTouched).toBe(true);
|
|
83
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { expect, test, describe } from "bun:test";
|
|
2
|
+
import { createForm } from "../index.js";
|
|
3
|
+
import { sleep } from "@opentf/std";
|
|
4
|
+
|
|
5
|
+
describe("Web App Framework Forms Library - State Helpers", () => {
|
|
6
|
+
test("isValid tracks error state", () => {
|
|
7
|
+
const form = createForm({
|
|
8
|
+
mode: "onChange",
|
|
9
|
+
initialValues: { name: "" },
|
|
10
|
+
validate: (v) => v.name ? {} : { name: "Error" }
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
expect(form.isValid).toBe(false);
|
|
14
|
+
form.values.name = "Alice";
|
|
15
|
+
expect(form.isValid).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("isChanged tracks deep equality with initial values", () => {
|
|
19
|
+
const form = createForm({
|
|
20
|
+
initialValues: { user: { name: "Alice" } }
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(form.isChanged).toBe(false);
|
|
24
|
+
expect(form.changed.user).toBe(false);
|
|
25
|
+
|
|
26
|
+
form.values.user = { name: "Bob" };
|
|
27
|
+
expect(form.isChanged).toBe(true);
|
|
28
|
+
expect(form.changed.user).toBe(true);
|
|
29
|
+
|
|
30
|
+
form.values.user = { name: "Alice" };
|
|
31
|
+
expect(form.isChanged).toBe(false);
|
|
32
|
+
expect(form.changed.user).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("isTouched tracks field blur state", () => {
|
|
36
|
+
const form = createForm({
|
|
37
|
+
initialValues: { email: "" }
|
|
38
|
+
});
|
|
39
|
+
const { onblur } = form.register("email");
|
|
40
|
+
|
|
41
|
+
expect(form.isTouched).toBe(false);
|
|
42
|
+
expect(form.touched.email).toBe(false);
|
|
43
|
+
|
|
44
|
+
onblur();
|
|
45
|
+
expect(form.isTouched).toBe(true);
|
|
46
|
+
expect(form.touched.email).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("isSubmitting and isSubmitted lifecycle", async () => {
|
|
50
|
+
let resolved = false;
|
|
51
|
+
const form = createForm({
|
|
52
|
+
initialValues: { name: "Alice" }
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const onSubmit = async () => {
|
|
56
|
+
await sleep(50);
|
|
57
|
+
resolved = true;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const handler = form.handleSubmit(onSubmit);
|
|
61
|
+
|
|
62
|
+
expect(form.isSubmitting).toBe(false);
|
|
63
|
+
expect(form.isSubmitted).toBe(false);
|
|
64
|
+
|
|
65
|
+
const promise = handler();
|
|
66
|
+
expect(form.isSubmitting).toBe(true);
|
|
67
|
+
|
|
68
|
+
await promise;
|
|
69
|
+
expect(resolved).toBe(true);
|
|
70
|
+
expect(form.isSubmitting).toBe(false);
|
|
71
|
+
expect(form.isSubmitted).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("reset() restores initial state", () => {
|
|
75
|
+
const form = createForm({
|
|
76
|
+
initialValues: { count: 0 }
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
form.values.count = 10;
|
|
80
|
+
form.register("count").onblur();
|
|
81
|
+
|
|
82
|
+
expect(form.values.count).toBe(10);
|
|
83
|
+
expect(form.isChanged).toBe(true);
|
|
84
|
+
expect(form.isTouched).toBe(true);
|
|
85
|
+
|
|
86
|
+
form.reset();
|
|
87
|
+
|
|
88
|
+
expect(form.values.count).toBe(0);
|
|
89
|
+
expect(form.isChanged).toBe(false);
|
|
90
|
+
expect(form.isTouched).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { expect, test, describe } from "bun:test";
|
|
2
|
+
import { render, userEvent } from "@opentf/web-test";
|
|
3
|
+
import { sleep } from "@opentf/std";
|
|
4
|
+
import StateForm from "./StateForm.jsx";
|
|
5
|
+
|
|
6
|
+
const waitFor = async (fn, timeout = 1000) => {
|
|
7
|
+
const start = Date.now();
|
|
8
|
+
while (Date.now() - start < timeout) {
|
|
9
|
+
try {
|
|
10
|
+
fn();
|
|
11
|
+
return;
|
|
12
|
+
} catch (e) {
|
|
13
|
+
await sleep(10);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
fn();
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
describe("Form State UI Reactivity", () => {
|
|
20
|
+
test("updates UI based on state helpers", async () => {
|
|
21
|
+
let submitted = false;
|
|
22
|
+
const { getByTestId, queryByTestId } = render(StateForm, { onSubmit: async () => {
|
|
23
|
+
submitted = true;
|
|
24
|
+
}});
|
|
25
|
+
const user = userEvent.setup();
|
|
26
|
+
|
|
27
|
+
const input = getByTestId("username");
|
|
28
|
+
const statusValid = getByTestId("status-valid");
|
|
29
|
+
const statusChanged = getByTestId("status-changed");
|
|
30
|
+
const statusTouched = getByTestId("status-touched");
|
|
31
|
+
const resetBtn = getByTestId("reset");
|
|
32
|
+
const submitBtn = getByTestId("submit");
|
|
33
|
+
|
|
34
|
+
// Initial state
|
|
35
|
+
expect(statusValid.textContent).toBe("Valid");
|
|
36
|
+
|
|
37
|
+
// Change value to invalid
|
|
38
|
+
await user.clear(input);
|
|
39
|
+
await user.type(input, "ab");
|
|
40
|
+
expect(statusValid.textContent).toBe("Invalid");
|
|
41
|
+
expect(statusChanged.textContent).toBe("Changed");
|
|
42
|
+
expect(getByTestId("error").textContent).toBe("Too short");
|
|
43
|
+
|
|
44
|
+
// Submit valid data
|
|
45
|
+
await user.clear(input);
|
|
46
|
+
await user.type(input, "bob");
|
|
47
|
+
await user.click(submitBtn);
|
|
48
|
+
|
|
49
|
+
await waitFor(() => expect(submitted).toBe(true));
|
|
50
|
+
await waitFor(() => expect(getByTestId("success")).toBeTruthy());
|
|
51
|
+
|
|
52
|
+
// Reset
|
|
53
|
+
await user.click(resetBtn);
|
|
54
|
+
expect(input.value).toBe("alice");
|
|
55
|
+
expect(statusChanged.textContent).toBe("Unchanged");
|
|
56
|
+
expect(queryByTestId("success")).toBeNull();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createForm } from "../index.js";
|
|
2
|
+
|
|
3
|
+
export default function StateForm({ onSubmit }) {
|
|
4
|
+
const form = createForm({
|
|
5
|
+
mode: "onChange", // Use onChange for easier testing of reactive text
|
|
6
|
+
initialValues: { username: "alice" },
|
|
7
|
+
validate: (v) => v.username.length < 3 ? { username: "Too short" } : {}
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// Submit is driven via onclick rather than a native `type="submit"` (which, in
|
|
11
|
+
// happy-dom, mangles the form subtree on submission regardless of
|
|
12
|
+
// preventDefault — a test-env quirk, not a real-browser behaviour).
|
|
13
|
+
return (
|
|
14
|
+
<div>
|
|
15
|
+
<input {...form.register("username")} data-testid="username" />
|
|
16
|
+
|
|
17
|
+
<div data-testid="status-valid">{form.isValid ? "Valid" : "Invalid"}</div>
|
|
18
|
+
<div data-testid="status-changed">{form.isChanged ? "Changed" : "Unchanged"}</div>
|
|
19
|
+
<div data-testid="status-touched">{form.isTouched ? "Touched" : "Untouched"}</div>
|
|
20
|
+
|
|
21
|
+
{form.errors.username && <span data-testid="error">{form.errors.username}</span>}
|
|
22
|
+
|
|
23
|
+
<button type="button" onclick={() => form.reset()} data-testid="reset">Reset</button>
|
|
24
|
+
<button type="button" onclick={form.handleSubmit(onSubmit)} data-testid="submit">Submit</button>
|
|
25
|
+
|
|
26
|
+
{form.isSubmitted && <div data-testid="success">Submitted!</div>}
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { expect, test, describe } from "bun:test";
|
|
2
|
+
import { createForm } from "../index.js";
|
|
3
|
+
|
|
4
|
+
describe("Web App Framework Forms Library - Validation Modes", () => {
|
|
5
|
+
test("mode: onBlur (default)", () => {
|
|
6
|
+
const form = createForm({
|
|
7
|
+
initialValues: { name: "" },
|
|
8
|
+
validate: (v) => v.name.length < 3 ? { name: "Short" } : {}
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
expect(form.errors.name).toBeUndefined();
|
|
12
|
+
|
|
13
|
+
// oninput should NOT trigger validation
|
|
14
|
+
form.register("name").oninput({ target: { value: "a" } });
|
|
15
|
+
expect(form.errors.name).toBeUndefined();
|
|
16
|
+
|
|
17
|
+
// onblur SHOULD trigger validation
|
|
18
|
+
form.register("name").onblur();
|
|
19
|
+
expect(form.errors.name).toBe("Short");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("mode: onChange", () => {
|
|
23
|
+
const form = createForm({
|
|
24
|
+
mode: "onChange",
|
|
25
|
+
initialValues: { name: "" },
|
|
26
|
+
validate: (v) => v.name.length < 3 ? { name: "Short" } : {}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
expect(form.errors.name).toBe("Short"); // Validates on init
|
|
30
|
+
|
|
31
|
+
form.register("name").oninput({ target: { value: "abc" } });
|
|
32
|
+
expect(form.errors.name).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("mode: onSubmit", async () => {
|
|
36
|
+
const form = createForm({
|
|
37
|
+
mode: "onSubmit",
|
|
38
|
+
initialValues: { name: "" },
|
|
39
|
+
validate: (v) => v.name.length < 3 ? { name: "Short" } : {}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
form.register("name").oninput({ target: { value: "a" } });
|
|
43
|
+
form.register("name").onblur();
|
|
44
|
+
expect(form.errors.name).toBeUndefined();
|
|
45
|
+
|
|
46
|
+
await form.handleSubmit(() => {})();
|
|
47
|
+
expect(form.errors.name).toBe("Short");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("reValidateMode: onChange (default)", () => {
|
|
51
|
+
const form = createForm({
|
|
52
|
+
mode: "onBlur", // Validate on blur first
|
|
53
|
+
initialValues: { name: "" },
|
|
54
|
+
validate: (v) => v.name.length < 3 ? { name: "Short" } : {}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
form.register("name").onblur();
|
|
58
|
+
expect(form.errors.name).toBe("Short");
|
|
59
|
+
|
|
60
|
+
// Once error exists, oninput SHOULD trigger re-validation
|
|
61
|
+
form.register("name").oninput({ target: { value: "abc" } });
|
|
62
|
+
expect(form.errors.name).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("reValidateMode: onBlur", () => {
|
|
66
|
+
const form = createForm({
|
|
67
|
+
mode: "onBlur",
|
|
68
|
+
reValidateMode: "onBlur",
|
|
69
|
+
initialValues: { name: "" },
|
|
70
|
+
validate: (v) => v.name.length < 3 ? { name: "Short" } : {}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
form.register("name").onblur();
|
|
74
|
+
expect(form.errors.name).toBe("Short");
|
|
75
|
+
|
|
76
|
+
// Should NOT re-validate on input
|
|
77
|
+
form.register("name").oninput({ target: { value: "abc" } });
|
|
78
|
+
expect(form.errors.name).toBe("Short");
|
|
79
|
+
|
|
80
|
+
// Should re-validate on blur
|
|
81
|
+
form.register("name").onblur();
|
|
82
|
+
expect(form.errors.name).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
});
|