@opentf/web-form 0.1.0 → 1.0.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 +8 -0
- package/README.md +141 -0
- package/bunfig.toml +6 -0
- package/index.js +343 -129
- package/package.json +12 -5
- package/tests/Basic.test.jsx +37 -0
- package/tests/BasicForm.jsx +29 -0
- package/tests/Component.test.jsx +79 -0
- package/tests/DynamicArrayForm.jsx +32 -0
- package/tests/DynamicArrays.test.jsx +33 -0
- package/tests/DynamicListForm.jsx +20 -0
- package/tests/Logic.test.js +153 -0
- package/tests/NestedFields.test.jsx +28 -0
- package/tests/NestedForm.jsx +32 -0
- package/tests/Reactivity.test.jsx +26 -0
- package/tests/RegisterMetadata.test.js +83 -0
- package/tests/State.test.js +93 -0
- package/tests/StateComponent.test.jsx +84 -0
- package/tests/ValidationModes.test.js +84 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opentf/web-form",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "A high-performance, path-based reactive forms
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A high-performance, path-based reactive forms engine for the native-first OTF Web.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"module": "index.js",
|
|
7
7
|
"type": "module",
|
|
@@ -9,12 +9,19 @@
|
|
|
9
9
|
".": "./index.js"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@
|
|
12
|
+
"@opentf/std": "^1.0.0-beta.2"
|
|
13
13
|
},
|
|
14
14
|
"peerDependencies": {
|
|
15
|
-
"
|
|
15
|
+
"@opentf/web": "workspace:*"
|
|
16
16
|
},
|
|
17
17
|
"publishConfig": {
|
|
18
18
|
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "bun test --env=happy-dom"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@opentf/web": "workspace:*",
|
|
25
|
+
"@opentf/web-test": "workspace:*"
|
|
19
26
|
}
|
|
20
|
-
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { expect, test, describe } from "bun:test";
|
|
2
|
+
import { render, userEvent } from "@opentf/web-test";
|
|
3
|
+
import BasicForm from "./BasicForm.jsx";
|
|
4
|
+
|
|
5
|
+
describe("Basic Form", () => {
|
|
6
|
+
test("initializes and handles submission", async () => {
|
|
7
|
+
let submittedValues = null;
|
|
8
|
+
const { getByTestId, queryByTestId } = render(BasicForm, {
|
|
9
|
+
onSubmit: (v) => submittedValues = v
|
|
10
|
+
});
|
|
11
|
+
const user = userEvent.setup();
|
|
12
|
+
|
|
13
|
+
const username = getByTestId("username");
|
|
14
|
+
const email = getByTestId("email");
|
|
15
|
+
const submit = getByTestId("submit");
|
|
16
|
+
|
|
17
|
+
expect(username.value).toBe("alice");
|
|
18
|
+
expect(email.value).toBe("");
|
|
19
|
+
|
|
20
|
+
// Try to submit with invalid email
|
|
21
|
+
await user.click(submit);
|
|
22
|
+
expect(submittedValues).toBeNull();
|
|
23
|
+
expect(getByTestId("email-error").textContent).toBe("Invalid email");
|
|
24
|
+
|
|
25
|
+
// Correct the email
|
|
26
|
+
await user.type(email, "alice@example.com");
|
|
27
|
+
expect(queryByTestId("email-error")).toBeNull();
|
|
28
|
+
|
|
29
|
+
// Change username
|
|
30
|
+
await user.clear(username);
|
|
31
|
+
await user.type(username, "bob");
|
|
32
|
+
|
|
33
|
+
// Submit
|
|
34
|
+
await user.click(submit);
|
|
35
|
+
expect(submittedValues).toEqual({ username: "bob", email: "alice@example.com" });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createForm } from "../index.js";
|
|
2
|
+
|
|
3
|
+
export default function BasicForm({ onSubmit }) {
|
|
4
|
+
const form = createForm({
|
|
5
|
+
initialValues: { username: "alice", email: "" },
|
|
6
|
+
validate: (values) => {
|
|
7
|
+
const errs = {};
|
|
8
|
+
if (!values.username) errs.username = "Username required";
|
|
9
|
+
if (!values.email.includes("@")) errs.email = "Invalid email";
|
|
10
|
+
return errs;
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<form onsubmit={form.handleSubmit(onSubmit)}>
|
|
16
|
+
<div>
|
|
17
|
+
<label for="username">Username</label>
|
|
18
|
+
<input {...form.register("username")} id="username" data-testid="username" />
|
|
19
|
+
{form.errors.username && <span data-testid="username-error">{form.errors.username}</span>}
|
|
20
|
+
</div>
|
|
21
|
+
<div>
|
|
22
|
+
<label for="email">Email</label>
|
|
23
|
+
<input {...form.register("email")} id="email" data-testid="email" />
|
|
24
|
+
{form.errors.email && <span data-testid="email-error">{form.errors.email}</span>}
|
|
25
|
+
</div>
|
|
26
|
+
<button type="submit" data-testid="submit">Submit</button>
|
|
27
|
+
</form>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { expect, test, describe } from "bun:test";
|
|
2
|
+
import { render, userEvent } from "@opentf/web-test";
|
|
3
|
+
import { createForm } from "../index.js";
|
|
4
|
+
|
|
5
|
+
export function ProfileForm() {
|
|
6
|
+
const form = createForm({
|
|
7
|
+
initialValues: {
|
|
8
|
+
user: { name: "John" },
|
|
9
|
+
tags: ["js"]
|
|
10
|
+
},
|
|
11
|
+
validate: (v) => {
|
|
12
|
+
const errors = {};
|
|
13
|
+
if (v.user.name.length < 3) errors.user = { name: "Too short" };
|
|
14
|
+
return errors;
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const addTag = () => form.values.tags.push(`tag-${form.values.tags.length}`);
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div>
|
|
22
|
+
<h1 data-testid="title">{form.values.user.name}</h1>
|
|
23
|
+
<input {...form.register("user.name")} data-testid="name-input" />
|
|
24
|
+
<div data-testid="name-error">{form.errors.user?.name}</div>
|
|
25
|
+
|
|
26
|
+
<ul data-testid="tag-list">
|
|
27
|
+
{form.values.tags.map((tag, i) => (
|
|
28
|
+
<li data-testid={`tag-${i}`}>{tag}</li>
|
|
29
|
+
))}
|
|
30
|
+
</ul>
|
|
31
|
+
<button onclick={addTag} data-testid="add-tag">Add Tag</button>
|
|
32
|
+
|
|
33
|
+
<div data-testid="status">{form.isValid ? "Valid" : "Invalid"}</div>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("Web Form Component Integration", () => {
|
|
39
|
+
test("updates nested values and validation state in UI", async () => {
|
|
40
|
+
const { getByTestId } = render(ProfileForm);
|
|
41
|
+
const user = userEvent.setup();
|
|
42
|
+
|
|
43
|
+
const input = getByTestId("name-input");
|
|
44
|
+
const error = getByTestId("name-error");
|
|
45
|
+
const status = getByTestId("status");
|
|
46
|
+
const title = getByTestId("title");
|
|
47
|
+
|
|
48
|
+
expect(title.textContent).toBe("John");
|
|
49
|
+
expect(status.textContent).toBe("Valid");
|
|
50
|
+
|
|
51
|
+
await user.clear(input);
|
|
52
|
+
await user.type(input, "Jo");
|
|
53
|
+
// Mode is onBlur by default, so we need to blur
|
|
54
|
+
await user.tab();
|
|
55
|
+
|
|
56
|
+
expect(title.textContent).toBe("Jo");
|
|
57
|
+
expect(error.textContent).toBe("Too short");
|
|
58
|
+
expect(status.textContent).toBe("Invalid");
|
|
59
|
+
|
|
60
|
+
await user.type(input, "hnny");
|
|
61
|
+
await user.tab();
|
|
62
|
+
expect(error.textContent).toBe("");
|
|
63
|
+
expect(status.textContent).toBe("Valid");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("handles dynamic array mutations in UI", async () => {
|
|
67
|
+
const { getByTestId, getAllByRole } = render(ProfileForm);
|
|
68
|
+
const user = userEvent.setup();
|
|
69
|
+
|
|
70
|
+
const list = getByTestId("tag-list");
|
|
71
|
+
const addBtn = getByTestId("add-tag");
|
|
72
|
+
|
|
73
|
+
expect(list.children.length).toBe(1);
|
|
74
|
+
|
|
75
|
+
await user.click(addBtn);
|
|
76
|
+
expect(list.children.length).toBe(2);
|
|
77
|
+
expect(getByTestId("tag-1").textContent).toBe("tag-1");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createForm } from "../index.js";
|
|
2
|
+
|
|
3
|
+
export default function DynamicArrayForm() {
|
|
4
|
+
const form = createForm({
|
|
5
|
+
initialValues: { items: ["Item 1"] }
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const addItem = () => {
|
|
9
|
+
const items = form.values.items;
|
|
10
|
+
form.values.items = [...items, `Item ${items.length + 1}`];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const removeItem = (index) => {
|
|
14
|
+
const newItems = [...form.values.items];
|
|
15
|
+
newItems.splice(index, 1);
|
|
16
|
+
form.values.items = newItems;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div>
|
|
21
|
+
<button onclick={addItem} data-testid="add-item">Add Item</button>
|
|
22
|
+
<ul data-testid="item-list">
|
|
23
|
+
{form.values.items.map((item, index) => (
|
|
24
|
+
<li data-testid={`item-${index}`}>
|
|
25
|
+
{item}
|
|
26
|
+
<button onclick={() => removeItem(index)} data-testid={`remove-${index}`}>Remove</button>
|
|
27
|
+
</li>
|
|
28
|
+
))}
|
|
29
|
+
</ul>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -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,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
|
+
});
|