@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
package/index.js
CHANGED
|
@@ -1,163 +1,350 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
signal,
|
|
3
|
+
computed,
|
|
4
|
+
effect,
|
|
5
|
+
batch,
|
|
6
|
+
untracked,
|
|
7
|
+
getCurrentInstance,
|
|
8
|
+
reactive,
|
|
9
|
+
toRawValue,
|
|
10
|
+
snapshot,
|
|
11
|
+
} from "@opentf/web";
|
|
12
|
+
|
|
13
|
+
export { signal, computed, batch, effect, untracked, reactive, getCurrentInstance };
|
|
14
|
+
|
|
15
|
+
import { clone, isEql } from "@opentf/std";
|
|
2
16
|
|
|
3
17
|
/**
|
|
4
|
-
*
|
|
18
|
+
* Path-based reactive forms engine for OTF Web.
|
|
19
|
+
*
|
|
20
|
+
* The form's values/errors/touched/changed trees are `reactive()` stores
|
|
21
|
+
* (@opentf/web), so `form.values.user.name` and `form.errors.email` are read
|
|
22
|
+
* directly in JSX with fine-grained reactivity — no provider object, no bespoke
|
|
23
|
+
* proxy. The engine layers field registration, validation modes, and submit
|
|
24
|
+
* orchestration on top.
|
|
5
25
|
*/
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
26
|
+
|
|
27
|
+
const INST_FORMS_REGISTRY = new WeakMap();
|
|
28
|
+
|
|
29
|
+
export function createForm(options = {}) {
|
|
30
|
+
// Scope the form to the mounting component so re-entrant `createForm` calls
|
|
31
|
+
// (e.g. the component body running again) return the same instance. Outside a
|
|
32
|
+
// component (module scope, tests) there is no instance — just build one.
|
|
33
|
+
const inst = getCurrentInstance();
|
|
34
|
+
if (!inst) return _createForm(options);
|
|
35
|
+
|
|
36
|
+
let forms = INST_FORMS_REGISTRY.get(inst);
|
|
37
|
+
if (!forms) INST_FORMS_REGISTRY.set(inst, (forms = new Map()));
|
|
38
|
+
|
|
39
|
+
const key = options.key || JSON.stringify({ initialValues: options.initialValues });
|
|
40
|
+
if (forms.has(key)) {
|
|
41
|
+
const form = forms.get(key);
|
|
42
|
+
form._updateConfig?.(options.mode, options.reValidateMode);
|
|
43
|
+
return form;
|
|
44
|
+
}
|
|
45
|
+
const form = _createForm(options);
|
|
46
|
+
forms.set(key, form);
|
|
47
|
+
return form;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function _createForm(options = {}) {
|
|
51
|
+
let initialValues = clone(options.initialValues || {});
|
|
52
|
+
|
|
53
|
+
const valuesStore = reactive(clone(initialValues));
|
|
54
|
+
const errorsStore = reactive({});
|
|
55
|
+
// touched mirrors the value tree with `false` leaves, so a field reads `false`
|
|
56
|
+
// (not undefined) before it is ever blurred. `changed` is filled by the
|
|
57
|
+
// creation effect below (top-level booleans vs. initial values).
|
|
58
|
+
const touchedStore = reactive(mirrorLeaves(initialValues, false));
|
|
59
|
+
const changedStore = reactive({});
|
|
60
|
+
|
|
61
|
+
const modeSig = signal(options.mode);
|
|
62
|
+
const reValidateModeSig = signal(options.reValidateMode);
|
|
63
|
+
const isSubmittingSig = signal(false);
|
|
64
|
+
const isValidatingSig = signal(false);
|
|
65
|
+
const isSubmittedSig = signal(false);
|
|
66
|
+
const submitCountSig = signal(0);
|
|
67
|
+
|
|
68
|
+
const normMode = (m) =>
|
|
69
|
+
((m && typeof m === "object" && "value" in m ? m.value : m) || "onBlur");
|
|
70
|
+
const normReValidate = (m) =>
|
|
71
|
+
((m && typeof m === "object" && "value" in m ? m.value : m) || "onChange");
|
|
72
|
+
|
|
73
|
+
const activeValidator = options.validator || options.validate;
|
|
74
|
+
|
|
75
|
+
// --- path helpers: walk the reactive store so reads subscribe & writes notify
|
|
76
|
+
function readPath(store, path) {
|
|
77
|
+
if (!path) return store;
|
|
78
|
+
let node = store;
|
|
79
|
+
for (const p of path.split(".")) {
|
|
80
|
+
if (node == null) return undefined;
|
|
81
|
+
node = node[p];
|
|
14
82
|
}
|
|
15
|
-
|
|
83
|
+
return node;
|
|
16
84
|
}
|
|
17
|
-
current[parts[parts.length - 1]] = value;
|
|
18
|
-
};
|
|
19
85
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
touched[path] = signal(false);
|
|
86
|
+
function writePath(store, path, value) {
|
|
87
|
+
const parts = path.split(".");
|
|
88
|
+
let node = store;
|
|
89
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
90
|
+
const p = parts[i];
|
|
91
|
+
let next = node[p];
|
|
92
|
+
if (next == null || typeof next !== "object") {
|
|
93
|
+
node[p] = {};
|
|
94
|
+
next = node[p];
|
|
95
|
+
}
|
|
96
|
+
node = next;
|
|
32
97
|
}
|
|
33
|
-
|
|
34
|
-
|
|
98
|
+
node[parts[parts.length - 1]] = value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function deletePath(store, path) {
|
|
102
|
+
const parts = path.split(".");
|
|
103
|
+
let node = store;
|
|
104
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
105
|
+
node = node[parts[i]];
|
|
106
|
+
if (node == null || typeof node !== "object") return;
|
|
35
107
|
}
|
|
36
|
-
|
|
37
|
-
|
|
108
|
+
delete node[parts[parts.length - 1]];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Reconcile a reactive store in place to match a plain object, touching only
|
|
112
|
+
// the leaves that actually differ (fine-grained — no wholesale replace).
|
|
113
|
+
function syncStore(store, next) {
|
|
114
|
+
next = next || {};
|
|
115
|
+
const raw = toRawValue(store);
|
|
116
|
+
for (const k of Object.keys(raw)) {
|
|
117
|
+
if (!(k in next)) delete store[k];
|
|
118
|
+
}
|
|
119
|
+
for (const k of Object.keys(next)) {
|
|
120
|
+
const nv = next[k];
|
|
121
|
+
const cur = raw[k];
|
|
122
|
+
if (nv && typeof nv === "object" && cur && typeof cur === "object") {
|
|
123
|
+
syncStore(store[k], nv);
|
|
124
|
+
} else if (!isEql(cur, nv)) {
|
|
125
|
+
store[k] = nv;
|
|
126
|
+
}
|
|
38
127
|
}
|
|
39
|
-
return fields[path];
|
|
40
128
|
}
|
|
41
129
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
130
|
+
// --- validation
|
|
131
|
+
const normalizeErrors = (r) => (r && r.errors !== undefined ? r.errors : r) || {};
|
|
132
|
+
|
|
133
|
+
function applyErrors(errorsObj, fieldPath) {
|
|
134
|
+
if (fieldPath) {
|
|
135
|
+
const fieldErr = readRaw(errorsObj, fieldPath);
|
|
136
|
+
if (fieldErr) writePath(errorsStore, fieldPath, fieldErr);
|
|
137
|
+
else deletePath(errorsStore, fieldPath);
|
|
138
|
+
} else {
|
|
139
|
+
syncStore(errorsStore, errorsObj);
|
|
50
140
|
}
|
|
51
|
-
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let validationToken = 0;
|
|
144
|
+
function runValidation(fieldPath) {
|
|
145
|
+
if (!activeValidator) return;
|
|
146
|
+
const token = ++validationToken;
|
|
147
|
+
const result = activeValidator(snapshot(valuesStore));
|
|
148
|
+
if (result instanceof Promise) {
|
|
149
|
+
isValidatingSig.value = true;
|
|
150
|
+
return result
|
|
151
|
+
.then((r) => {
|
|
152
|
+
if (token === validationToken) applyErrors(normalizeErrors(r), fieldPath);
|
|
153
|
+
})
|
|
154
|
+
.finally(() => {
|
|
155
|
+
if (token === validationToken) isValidatingSig.value = false;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
applyErrors(normalizeErrors(result), fieldPath);
|
|
159
|
+
}
|
|
52
160
|
|
|
53
|
-
//
|
|
54
|
-
|
|
161
|
+
// --- changed tracking: per top-level key, deep-compared to initial values.
|
|
162
|
+
function recomputeChanged() {
|
|
163
|
+
const v = toRawValue(valuesStore);
|
|
164
|
+
const keys = new Set([...Object.keys(v), ...Object.keys(initialValues)]);
|
|
165
|
+
for (const k of keys) {
|
|
166
|
+
const ch = !isEql(v[k], initialValues[k]);
|
|
167
|
+
if (toRawValue(changedStore)[k] !== ch) changedStore[k] = ch;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
55
170
|
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
171
|
+
// One effect observes every value change and (a) recomputes `changed`, and
|
|
172
|
+
// (b) in onChange mode, revalidates — covering inputs, direct assignment, and
|
|
173
|
+
// array mutation alike. Writes are untracked so it never depends on its own
|
|
174
|
+
// output. Validates once on creation, satisfying onChange's "validate on init".
|
|
175
|
+
effect(() => {
|
|
176
|
+
deepTrack(valuesStore);
|
|
177
|
+
const mode = normMode(modeSig.value);
|
|
178
|
+
untracked(() => {
|
|
179
|
+
recomputeChanged();
|
|
180
|
+
if (mode === "onChange") runValidation();
|
|
61
181
|
});
|
|
62
|
-
return res;
|
|
63
182
|
});
|
|
64
183
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
errors.value = result.errors || {};
|
|
73
|
-
} else if (validate) {
|
|
74
|
-
errors.value = validate(currentValues) || {};
|
|
184
|
+
// --- field registration
|
|
185
|
+
function updateValue(path, val) {
|
|
186
|
+
writePath(valuesStore, path, val);
|
|
187
|
+
const mode = normMode(modeSig.peek());
|
|
188
|
+
if (mode === "onChange") return; // the effect above revalidates
|
|
189
|
+
if (hasTruthy(toRawValue(errorsStore)) && normReValidate(reValidateModeSig.peek()) === "onChange") {
|
|
190
|
+
runValidation(path);
|
|
75
191
|
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const register = (path) => ({
|
|
195
|
+
name: path,
|
|
196
|
+
value: readPath(valuesStore, path),
|
|
197
|
+
checked: readPath(valuesStore, path),
|
|
198
|
+
error: computed(() => readPath(errorsStore, path)),
|
|
199
|
+
isTouched: computed(() => readPath(touchedStore, path) ?? false),
|
|
200
|
+
oninput: (e) => {
|
|
201
|
+
const t = e.target;
|
|
202
|
+
updateValue(path, t.type === "checkbox" ? t.checked : t.value);
|
|
203
|
+
},
|
|
204
|
+
onblur: () => {
|
|
205
|
+
writePath(touchedStore, path, true);
|
|
206
|
+
const mode = normMode(modeSig.peek());
|
|
207
|
+
if (mode === "onBlur") runValidation(path);
|
|
208
|
+
else if (
|
|
209
|
+
hasTruthy(toRawValue(errorsStore)) &&
|
|
210
|
+
normReValidate(reValidateModeSig.peek()) === "onBlur"
|
|
211
|
+
) {
|
|
212
|
+
runValidation(path);
|
|
213
|
+
}
|
|
214
|
+
},
|
|
76
215
|
});
|
|
77
216
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
217
|
+
// --- submit / reset
|
|
218
|
+
const handleSubmit = (fn) => async (e) => {
|
|
219
|
+
if (e && e.preventDefault) e.preventDefault();
|
|
220
|
+
syncStore(touchedStore, mirrorLeaves(snapshot(valuesStore), true));
|
|
221
|
+
isSubmittingSig.value = true;
|
|
222
|
+
submitCountSig.value++;
|
|
223
|
+
await runValidation();
|
|
224
|
+
if (!hasTruthy(toRawValue(errorsStore))) {
|
|
225
|
+
try {
|
|
226
|
+
await fn(snapshot(valuesStore));
|
|
227
|
+
isSubmittedSig.value = true;
|
|
228
|
+
} finally {
|
|
229
|
+
isSubmittingSig.value = false;
|
|
88
230
|
}
|
|
89
|
-
}
|
|
90
|
-
|
|
231
|
+
} else {
|
|
232
|
+
isSubmittingSig.value = false;
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const reset = (newValues) => {
|
|
237
|
+
batch(() => {
|
|
238
|
+
if (newValues !== undefined) initialValues = clone(newValues);
|
|
239
|
+
syncStore(valuesStore, clone(initialValues));
|
|
240
|
+
syncStore(errorsStore, {});
|
|
241
|
+
syncStore(touchedStore, mirrorLeaves(initialValues, false));
|
|
242
|
+
syncStore(changedStore, {});
|
|
243
|
+
isSubmittedSig.value = false;
|
|
244
|
+
});
|
|
245
|
+
};
|
|
91
246
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
247
|
+
// --- derived flags
|
|
248
|
+
const isValidSig = computed(() => !hasTruthy(errorsStore));
|
|
249
|
+
const isTouchedSig = computed(() => hasTruthy(touchedStore));
|
|
250
|
+
const isChangedSig = computed(() => hasTruthy(changedStore));
|
|
251
|
+
|
|
252
|
+
const form = {
|
|
253
|
+
register,
|
|
254
|
+
handleSubmit,
|
|
255
|
+
reset,
|
|
256
|
+
_updateConfig(newMode, newReValidateMode) {
|
|
97
257
|
batch(() => {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
});
|
|
258
|
+
modeSig.value = newMode;
|
|
259
|
+
reValidateModeSig.value = newReValidateMode;
|
|
101
260
|
});
|
|
261
|
+
},
|
|
262
|
+
_notifySignals() {}, // legacy no-op: reactive stores notify on write
|
|
263
|
+
get values() { return valuesStore; },
|
|
264
|
+
get errors() { return errorsStore; },
|
|
265
|
+
get touched() { return touchedStore; },
|
|
266
|
+
get changed() { return changedStore; },
|
|
267
|
+
_signals: {
|
|
268
|
+
values: storeSignal(valuesStore),
|
|
269
|
+
errors: storeSignal(errorsStore),
|
|
270
|
+
touched: storeSignal(touchedStore),
|
|
271
|
+
changed: storeSignal(changedStore),
|
|
272
|
+
},
|
|
273
|
+
};
|
|
102
274
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (Object.keys(currentErrors).length === 0) {
|
|
114
|
-
onSubmit(currentValues);
|
|
115
|
-
}
|
|
116
|
-
};
|
|
117
|
-
}
|
|
275
|
+
Object.defineProperties(form, {
|
|
276
|
+
isValid: { get: () => isValidSig.value, enumerable: true },
|
|
277
|
+
isChanged: { get: () => isChangedSig.value, enumerable: true },
|
|
278
|
+
isTouched: { get: () => isTouchedSig.value, enumerable: true },
|
|
279
|
+
isSubmitting: { get: () => isSubmittingSig.value, enumerable: true },
|
|
280
|
+
isValidating: { get: () => isValidatingSig.value, enumerable: true },
|
|
281
|
+
isSubmitted: { get: () => isSubmittedSig.value, enumerable: true },
|
|
282
|
+
submitCount: { get: () => submitCountSig.value, enumerable: true },
|
|
283
|
+
});
|
|
118
284
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
285
|
+
return form;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// --- shared helpers (no per-form state)
|
|
289
|
+
|
|
290
|
+
/** Raw (non-reactive) deep read by dot-path. */
|
|
291
|
+
function readRaw(obj, path) {
|
|
292
|
+
let node = obj;
|
|
293
|
+
for (const p of path.split(".")) {
|
|
294
|
+
if (node == null) return undefined;
|
|
295
|
+
node = node[p];
|
|
128
296
|
}
|
|
297
|
+
return node;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Whether any leaf is truthy — the basis of isValid/isTouched/isChanged. */
|
|
301
|
+
function hasTruthy(obj) {
|
|
302
|
+
if (obj == null || typeof obj !== "object") return !!obj;
|
|
303
|
+
return Object.values(obj).some(hasTruthy);
|
|
304
|
+
}
|
|
129
305
|
|
|
130
|
-
|
|
131
|
-
|
|
306
|
+
/** Mirror a tree's structure, replacing every leaf with `leaf`. */
|
|
307
|
+
function mirrorLeaves(obj, leaf) {
|
|
308
|
+
if (obj == null || typeof obj !== "object") return leaf;
|
|
309
|
+
const res = Array.isArray(obj) ? [] : {};
|
|
310
|
+
for (const k of Object.keys(obj)) res[k] = mirrorLeaves(obj[k], leaf);
|
|
311
|
+
return res;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Subscribe the active consumer to every path in a store (deep). */
|
|
315
|
+
function deepTrack(store) {
|
|
316
|
+
const raw = toRawValue(store);
|
|
317
|
+
if (raw == null || typeof raw !== "object") return;
|
|
318
|
+
for (const k of Object.keys(store)) {
|
|
319
|
+
const v = store[k];
|
|
320
|
+
if (v && typeof v === "object") deepTrack(v);
|
|
132
321
|
}
|
|
322
|
+
}
|
|
133
323
|
|
|
324
|
+
/**
|
|
325
|
+
* A preact-style signal facade over a reactive store for the advanced `_signals`
|
|
326
|
+
* API: `.value` snapshots/replaces the whole tree, `.subscribe(cb)` fires
|
|
327
|
+
* immediately then once per change.
|
|
328
|
+
*/
|
|
329
|
+
function storeSignal(store) {
|
|
134
330
|
return {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
writable: true
|
|
154
|
-
} : undefined;
|
|
155
|
-
}
|
|
156
|
-
}),
|
|
157
|
-
errors,
|
|
158
|
-
touched: new Proxy({}, {
|
|
159
|
-
get: (_, key) => touched[key]
|
|
160
|
-
}),
|
|
161
|
-
fields // Also expose the raw signals map for advanced usage
|
|
331
|
+
get value() {
|
|
332
|
+
return snapshot(store);
|
|
333
|
+
},
|
|
334
|
+
set value(v) {
|
|
335
|
+
const next = v || {};
|
|
336
|
+
const raw = toRawValue(store);
|
|
337
|
+
for (const k of Object.keys(raw)) if (!(k in next)) delete store[k];
|
|
338
|
+
for (const k of Object.keys(next)) store[k] = next[k];
|
|
339
|
+
},
|
|
340
|
+
peek() {
|
|
341
|
+
return toRawValue(store);
|
|
342
|
+
},
|
|
343
|
+
subscribe(cb) {
|
|
344
|
+
return effect(() => {
|
|
345
|
+
deepTrack(store);
|
|
346
|
+
cb(snapshot(store));
|
|
347
|
+
});
|
|
348
|
+
},
|
|
162
349
|
};
|
|
163
350
|
}
|
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.1.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": "*"
|
|
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": "0.6.0",
|
|
25
|
+
"@opentf/web-test": "1.1.0"
|
|
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,46 @@
|
|
|
1
|
+
import { expect, test, describe } from "bun:test";
|
|
2
|
+
import { render, userEvent } from "@opentf/web-test";
|
|
3
|
+
import ProfileForm from "./ProfileForm.jsx";
|
|
4
|
+
|
|
5
|
+
describe("Web Form Component Integration", () => {
|
|
6
|
+
test("updates nested values and validation state in UI", async () => {
|
|
7
|
+
const { getByTestId } = render(ProfileForm);
|
|
8
|
+
const user = userEvent.setup();
|
|
9
|
+
|
|
10
|
+
const input = getByTestId("name-input");
|
|
11
|
+
const error = getByTestId("name-error");
|
|
12
|
+
const status = getByTestId("status");
|
|
13
|
+
const title = getByTestId("title");
|
|
14
|
+
|
|
15
|
+
expect(title.textContent).toBe("John");
|
|
16
|
+
expect(status.textContent).toBe("Valid");
|
|
17
|
+
|
|
18
|
+
await user.clear(input);
|
|
19
|
+
await user.type(input, "Jo");
|
|
20
|
+
// Mode is onBlur by default, so we need to blur
|
|
21
|
+
await user.tab();
|
|
22
|
+
|
|
23
|
+
expect(title.textContent).toBe("Jo");
|
|
24
|
+
expect(error.textContent).toBe("Too short");
|
|
25
|
+
expect(status.textContent).toBe("Invalid");
|
|
26
|
+
|
|
27
|
+
await user.type(input, "hnny");
|
|
28
|
+
await user.tab();
|
|
29
|
+
expect(error.textContent).toBe("");
|
|
30
|
+
expect(status.textContent).toBe("Valid");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("handles dynamic array mutations in UI", async () => {
|
|
34
|
+
const { getByTestId, getAllByRole } = render(ProfileForm);
|
|
35
|
+
const user = userEvent.setup();
|
|
36
|
+
|
|
37
|
+
const list = getByTestId("tag-list");
|
|
38
|
+
const addBtn = getByTestId("add-tag");
|
|
39
|
+
|
|
40
|
+
expect(list.children.length).toBe(1);
|
|
41
|
+
|
|
42
|
+
await user.click(addBtn);
|
|
43
|
+
expect(list.children.length).toBe(2);
|
|
44
|
+
expect(getByTestId("tag-1").textContent).toBe("tag-1");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -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
|
+
}
|