juststore 1.2.0 → 1.2.1
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/README.md +3 -2
- package/dist/form.js +35 -19
- package/package.json +1 -1
- package/dist/src/atom.d.ts +0 -45
- package/dist/src/atom.js +0 -141
- package/dist/src/form.d.ts +0 -97
- package/dist/src/form.js +0 -176
- package/dist/src/impl.d.ts +0 -128
- package/dist/src/impl.js +0 -644
- package/dist/src/index.d.ts +0 -10
- package/dist/src/index.js +0 -7
- package/dist/src/kv_store.d.ts +0 -29
- package/dist/src/kv_store.js +0 -127
- package/dist/src/local_storage.d.ts +0 -7
- package/dist/src/local_storage.js +0 -43
- package/dist/src/memory.d.ts +0 -54
- package/dist/src/memory.js +0 -55
- package/dist/src/mixed_state.d.ts +0 -20
- package/dist/src/mixed_state.js +0 -45
- package/dist/src/node.d.ts +0 -41
- package/dist/src/node.js +0 -374
- package/dist/src/path.d.ts +0 -136
- package/dist/src/path.js +0 -26
- package/dist/src/root.d.ts +0 -23
- package/dist/src/root.js +0 -81
- package/dist/src/stable_keys.d.ts +0 -4
- package/dist/src/stable_keys.js +0 -31
- package/dist/src/store.d.ts +0 -42
- package/dist/src/store.js +0 -40
- package/dist/src/types.d.ts +0 -143
- package/dist/src/types.js +0 -1
- package/dist/src/utils.d.ts +0 -72
- package/dist/src/utils.js +0 -76
package/README.md
CHANGED
|
@@ -424,7 +424,8 @@ Additional form methods:
|
|
|
424
424
|
- `.error`
|
|
425
425
|
- `.setError(message | undefined)`
|
|
426
426
|
- `.clearErrors()`
|
|
427
|
-
- `.handleSubmit(onSubmit)`
|
|
427
|
+
- `.handleSubmit(onSubmit)` — clears stale errors, validates current values, then calls `onSubmit`
|
|
428
|
+
only when current validation passes
|
|
428
429
|
|
|
429
430
|
### `createMixedState(...states)`
|
|
430
431
|
|
|
@@ -513,4 +514,4 @@ Available on all nodes (`store.a.b.c`):
|
|
|
513
514
|
|
|
514
515
|
## License
|
|
515
516
|
|
|
516
|
-
|
|
517
|
+
MIT
|
package/dist/form.js
CHANGED
|
@@ -49,18 +49,38 @@ function createForm(namespace, defaultValue, fieldConfigs = {}) {
|
|
|
49
49
|
const storeApi = createStoreRoot(namespace, defaultValue, {
|
|
50
50
|
memoryOnly: true
|
|
51
51
|
});
|
|
52
|
+
const store = createFormProxy(storeApi, errorStore);
|
|
53
|
+
const validators = new Map();
|
|
54
|
+
let proxy;
|
|
55
|
+
for (const entry of Object.entries(fieldConfigs)) {
|
|
56
|
+
const [path, config] = entry;
|
|
57
|
+
const validator = getValidator(path, config?.validate);
|
|
58
|
+
if (validator) {
|
|
59
|
+
validators.set(path, validator);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const validate = () => {
|
|
63
|
+
let hasError = false;
|
|
64
|
+
for (const [path, validator] of validators) {
|
|
65
|
+
const error = validator(storeApi.value(path), proxy);
|
|
66
|
+
if (error) {
|
|
67
|
+
errorStore.set(path, error);
|
|
68
|
+
hasError = true;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return !hasError;
|
|
72
|
+
};
|
|
52
73
|
const formApi = {
|
|
53
74
|
clearErrors: () => produce(errorNamespace, undefined, false, true),
|
|
54
75
|
handleSubmit: (onSubmit) => (e) => {
|
|
55
76
|
e.preventDefault();
|
|
56
|
-
|
|
57
|
-
if (
|
|
77
|
+
formApi.clearErrors();
|
|
78
|
+
if (validate()) {
|
|
58
79
|
onSubmit(getSnapshot(namespace, true));
|
|
59
80
|
}
|
|
60
81
|
}
|
|
61
82
|
};
|
|
62
|
-
|
|
63
|
-
const proxy = new Proxy(formApi, {
|
|
83
|
+
proxy = new Proxy(formApi, {
|
|
64
84
|
get(target, prop) {
|
|
65
85
|
if (prop in target) {
|
|
66
86
|
return target[prop];
|
|
@@ -69,21 +89,17 @@ function createForm(namespace, defaultValue, fieldConfigs = {}) {
|
|
|
69
89
|
}
|
|
70
90
|
});
|
|
71
91
|
const unsubscribeFns = [];
|
|
72
|
-
for (const
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
});
|
|
85
|
-
unsubscribeFns.push(unsubscribe);
|
|
86
|
-
}
|
|
92
|
+
for (const [path, validator] of validators) {
|
|
93
|
+
const unsubscribe = storeApi.subscribe(path, value => {
|
|
94
|
+
const error = validator(value, proxy);
|
|
95
|
+
if (!error) {
|
|
96
|
+
errorStore.reset(path);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
errorStore.set(path, error);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
unsubscribeFns.push(unsubscribe);
|
|
87
103
|
}
|
|
88
104
|
return [proxy, unsubscribeFns];
|
|
89
105
|
}
|
package/package.json
CHANGED
package/dist/src/atom.d.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
export { type Atom, createAtom };
|
|
2
|
-
/**
|
|
3
|
-
* An atom is a value that can be subscribed to and updated.
|
|
4
|
-
*
|
|
5
|
-
* @param T - The type of the value
|
|
6
|
-
* @returns The atom
|
|
7
|
-
*/
|
|
8
|
-
type Atom<T> = {
|
|
9
|
-
/** The current value. */
|
|
10
|
-
readonly value: T;
|
|
11
|
-
/** Subscribe to the value. */
|
|
12
|
-
use: () => T;
|
|
13
|
-
/** Set the value. */
|
|
14
|
-
set: AtomSetState<T>;
|
|
15
|
-
/** Reset the value to the default value. */
|
|
16
|
-
reset: () => void;
|
|
17
|
-
/** Subscribe to the value.with a callback function. */
|
|
18
|
-
subscribe: (listener: (value: T) => void) => () => void;
|
|
19
|
-
/** Compute a derived value from the current value, similar to useState + useMemo */
|
|
20
|
-
useCompute: <R>(fn: (value: T) => R, deps?: readonly unknown[]) => R;
|
|
21
|
-
};
|
|
22
|
-
type AtomSetState<T> = (value: T | ((prev: T) => T)) => void;
|
|
23
|
-
/**
|
|
24
|
-
* Creates an atom with a given id and default value.
|
|
25
|
-
*
|
|
26
|
-
* @param id - The id of the atom
|
|
27
|
-
* @param defaultValue - The default value of the atom
|
|
28
|
-
* @returns The atom
|
|
29
|
-
* @example
|
|
30
|
-
* const stateA = createAtom(useId(), false)
|
|
31
|
-
* return (
|
|
32
|
-
* <>
|
|
33
|
-
* <ComponentA/>
|
|
34
|
-
* <ComponentB/>
|
|
35
|
-
* <Render state={stateA}>
|
|
36
|
-
* {(value, setValue) => (
|
|
37
|
-
* <button onClick={() => setValue(!value)}>{value ? 'Hide' : 'Show'}</button>
|
|
38
|
-
* )}
|
|
39
|
-
* </Render>
|
|
40
|
-
* <ComponentC/>
|
|
41
|
-
* <ComponentD/>
|
|
42
|
-
* </>
|
|
43
|
-
* )
|
|
44
|
-
*/
|
|
45
|
-
declare function createAtom<T>(id: string, defaultValue: T, persistent?: boolean): Atom<T>;
|
package/dist/src/atom.js
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
import { useSyncExternalStore } from "react";
|
|
2
|
-
import { getSnapshot, updateSnapshot, useCompute } from "./impl";
|
|
3
|
-
export { createAtom };
|
|
4
|
-
/**
|
|
5
|
-
* Creates an atom with a given id and default value.
|
|
6
|
-
*
|
|
7
|
-
* @param id - The id of the atom
|
|
8
|
-
* @param defaultValue - The default value of the atom
|
|
9
|
-
* @returns The atom
|
|
10
|
-
* @example
|
|
11
|
-
* const stateA = createAtom(useId(), false)
|
|
12
|
-
* return (
|
|
13
|
-
* <>
|
|
14
|
-
* <ComponentA/>
|
|
15
|
-
* <ComponentB/>
|
|
16
|
-
* <Render state={stateA}>
|
|
17
|
-
* {(value, setValue) => (
|
|
18
|
-
* <button onClick={() => setValue(!value)}>{value ? 'Hide' : 'Show'}</button>
|
|
19
|
-
* )}
|
|
20
|
-
* </Render>
|
|
21
|
-
* <ComponentC/>
|
|
22
|
-
* <ComponentD/>
|
|
23
|
-
* </>
|
|
24
|
-
* )
|
|
25
|
-
*/
|
|
26
|
-
function createAtom(id, defaultValue, persistent = false) {
|
|
27
|
-
const key = `atom:${id}`;
|
|
28
|
-
const memoryOnly = !persistent;
|
|
29
|
-
// set the default value
|
|
30
|
-
// so getAtom will never return undefined
|
|
31
|
-
if (getAtom(key, memoryOnly) === undefined) {
|
|
32
|
-
setAtom(key, defaultValue, memoryOnly);
|
|
33
|
-
}
|
|
34
|
-
const atomProxy = new Proxy({}, {
|
|
35
|
-
get(target, prop) {
|
|
36
|
-
const cacheKey = `_${String(prop)}`;
|
|
37
|
-
if (cacheKey in target) {
|
|
38
|
-
// return cached methods first
|
|
39
|
-
return target[cacheKey];
|
|
40
|
-
}
|
|
41
|
-
if (prop === "value") {
|
|
42
|
-
return getAtom(key, memoryOnly);
|
|
43
|
-
}
|
|
44
|
-
if (prop === "use") {
|
|
45
|
-
return (target._use ??= () => useAtom(key, memoryOnly));
|
|
46
|
-
}
|
|
47
|
-
if (prop === "set") {
|
|
48
|
-
return (target._set ??= (value) => setAtom(key, value, memoryOnly));
|
|
49
|
-
}
|
|
50
|
-
if (prop === "reset") {
|
|
51
|
-
return (target._reset ??= () => setAtom(key, defaultValue, memoryOnly));
|
|
52
|
-
}
|
|
53
|
-
if (prop === "subscribe") {
|
|
54
|
-
return (target._subscribe ??= (listener) => subscribeAtom(key, memoryOnly, listener));
|
|
55
|
-
}
|
|
56
|
-
if (prop === "useCompute") {
|
|
57
|
-
return (target._useCompute ??= (fn, deps) => useCompute(key, undefined, fn, deps, memoryOnly));
|
|
58
|
-
}
|
|
59
|
-
return undefined;
|
|
60
|
-
},
|
|
61
|
-
});
|
|
62
|
-
return atomProxy;
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* React hook that subscribes to and reads a value at a path.
|
|
66
|
-
*
|
|
67
|
-
* Uses useSyncExternalStore for tear-free reads and automatic re-rendering
|
|
68
|
-
* when the subscribed value changes.
|
|
69
|
-
*
|
|
70
|
-
* @param key - The namespace
|
|
71
|
-
* @param memoryOnly - When true, skips localStorage persistence
|
|
72
|
-
* @returns The current value at the namespace, or the default value if not set
|
|
73
|
-
*/
|
|
74
|
-
function useAtom(key, memoryOnly = true) {
|
|
75
|
-
const value = useSyncExternalStore((listener) => subscribeAtom(key, memoryOnly, listener), () => getSnapshot(key, memoryOnly), () => getSnapshot(key, memoryOnly));
|
|
76
|
-
return value;
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* Gets a value from an atom.
|
|
80
|
-
*
|
|
81
|
-
* @param key - The namespace
|
|
82
|
-
* @returns The value, or the default value if not set
|
|
83
|
-
*/
|
|
84
|
-
function getAtom(key, memoryOnly = true) {
|
|
85
|
-
return getSnapshot(key, memoryOnly);
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* Sets a value at a specific path within a namespace.
|
|
89
|
-
*
|
|
90
|
-
* @param key - The namespace
|
|
91
|
-
* @param value - The value to set
|
|
92
|
-
* @param memoryOnly - When true, skips localStorage persistence
|
|
93
|
-
*/
|
|
94
|
-
function setAtom(key, value, memoryOnly = true) {
|
|
95
|
-
if (typeof value !== "function") {
|
|
96
|
-
updateSnapshot(key, value, memoryOnly);
|
|
97
|
-
}
|
|
98
|
-
else {
|
|
99
|
-
updateSnapshot(key, value(getAtom(key, memoryOnly)), memoryOnly);
|
|
100
|
-
}
|
|
101
|
-
notifyAtom(key);
|
|
102
|
-
}
|
|
103
|
-
const listeners = new Map();
|
|
104
|
-
/**
|
|
105
|
-
* Subscribes to changes for an atom.
|
|
106
|
-
*
|
|
107
|
-
* @param key - The full key path to subscribe to
|
|
108
|
-
* @param listener - Callback invoked when the value changes
|
|
109
|
-
* @returns An unsubscribe function to remove the listener
|
|
110
|
-
*/
|
|
111
|
-
function subscribeAtom(key, memoryOnly, listener) {
|
|
112
|
-
let listenerSet = listeners.get(key);
|
|
113
|
-
if (!listenerSet) {
|
|
114
|
-
listenerSet = new Set();
|
|
115
|
-
listeners.set(key, listenerSet);
|
|
116
|
-
}
|
|
117
|
-
const atomListener = () => listener(getAtom(key, memoryOnly));
|
|
118
|
-
listenerSet.add(atomListener);
|
|
119
|
-
return () => {
|
|
120
|
-
const keyListeners = listeners.get(key);
|
|
121
|
-
if (keyListeners) {
|
|
122
|
-
keyListeners.delete(atomListener);
|
|
123
|
-
if (keyListeners.size === 0) {
|
|
124
|
-
listeners.delete(key);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
/**
|
|
130
|
-
* Notifies all listeners for an atom.
|
|
131
|
-
*
|
|
132
|
-
* @param namespace - The namespace
|
|
133
|
-
*/
|
|
134
|
-
function notifyAtom(namespace) {
|
|
135
|
-
const listenerSet = listeners.get(namespace);
|
|
136
|
-
if (!listenerSet)
|
|
137
|
-
return;
|
|
138
|
-
for (const listener of listenerSet) {
|
|
139
|
-
listener();
|
|
140
|
-
}
|
|
141
|
-
}
|
package/dist/src/form.d.ts
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import type { FieldPath, FieldPathValue, FieldValues, IsEqual } from "./path";
|
|
2
|
-
import type { ArrayProxy, DerivedStateProps, ObjectMutationMethods, Prettify, ValueState } from "./types";
|
|
3
|
-
export { type CreateFormOptions, createForm, type DeepNonNullable, type FormArrayState, type FormObjectState, type FormState, type FormStore, type FormValueState, useForm, };
|
|
4
|
-
/**
|
|
5
|
-
* Common form field methods available on every form state node.
|
|
6
|
-
*/
|
|
7
|
-
type FormCommon = {
|
|
8
|
-
/** Subscribe and read the validation error. Re-renders when the error changes. */
|
|
9
|
-
useError: () => string | undefined;
|
|
10
|
-
/** Read the validation error without subscribing. */
|
|
11
|
-
readonly error: string | undefined;
|
|
12
|
-
/** Manually set a validation error. */
|
|
13
|
-
setError: (error: string | undefined) => void;
|
|
14
|
-
};
|
|
15
|
-
type FormState<T> = IsEqual<T, unknown> extends true ? never : [NonNullable<T>] extends [readonly (infer U)[]] ? FormArrayState<U, T> : [NonNullable<T>] extends [FieldValues] ? FormObjectState<NonNullable<T>, T> : FormValueState<T>;
|
|
16
|
-
type FormReadOnlyState<T> = Prettify<Pick<FormValueState<Readonly<Required<T>>>, "value" | "use" | "useCompute" | "error" | "setError">>;
|
|
17
|
-
interface FormValueState<T> extends Omit<ValueState<T>, "ensureArray" | "ensureObject" | "withDefault" | "derived">, FormCommon {
|
|
18
|
-
/** Ensure the value is an array. */
|
|
19
|
-
ensureArray(): NonNullable<T> extends (infer U)[] ? FormArrayState<U> : never;
|
|
20
|
-
/** Ensure the value is an object. */
|
|
21
|
-
ensureObject(): NonNullable<T> extends FieldValues ? FormObjectState<NonNullable<T>> : never;
|
|
22
|
-
/** Return a new state with a default value, and make the type non-nullable */
|
|
23
|
-
withDefault(defaultValue: T): FormState<NonNullable<T>>;
|
|
24
|
-
/** Virtual state derived from the current value.
|
|
25
|
-
*
|
|
26
|
-
* @returns ArrayState if the derived value is an array, ObjectState if the derived value is an object, otherwise State.
|
|
27
|
-
* @example
|
|
28
|
-
* const state = store.a.b.c.derived({
|
|
29
|
-
* from: value => value + 1,
|
|
30
|
-
* to: value => value - 1
|
|
31
|
-
* })
|
|
32
|
-
* state.use() // returns the derived value
|
|
33
|
-
* state.set(10) // sets the derived value
|
|
34
|
-
* state.reset() // resets the derived value
|
|
35
|
-
*/
|
|
36
|
-
derived: <R>({ from, to }: DerivedStateProps<T, R>) => FormState<R>;
|
|
37
|
-
}
|
|
38
|
-
type FormObjectProxy<T extends FieldValues> = {
|
|
39
|
-
/** Virtual state for the object's keys.
|
|
40
|
-
*
|
|
41
|
-
* This does NOT read from a real `keys` property on the stored object; it results in a stable array of keys.
|
|
42
|
-
*/
|
|
43
|
-
readonly keys: FormReadOnlyState<FieldPath<T>[]>;
|
|
44
|
-
} & {
|
|
45
|
-
[K in keyof T]-?: FormState<T[K]>;
|
|
46
|
-
};
|
|
47
|
-
type FormArrayState<TValue, TArray = TValue[]> = IsEqual<TValue, unknown> extends true ? never : FormValueState<TArray> & ArrayProxy<TValue, FormState<TValue>>;
|
|
48
|
-
type FormObjectState<TNonNullable extends FieldValues, TObject = TNonNullable> = FormObjectProxy<TNonNullable> & FormValueState<TObject> & ObjectMutationMethods;
|
|
49
|
-
/** Type for nested objects with proxy methods */
|
|
50
|
-
type DeepNonNullable<T> = [NonNullable<T>] extends [readonly (infer U)[]] ? U[] : [NonNullable<T>] extends [FieldValues] ? {
|
|
51
|
-
[K in keyof NonNullable<T>]-?: DeepNonNullable<NonNullable<T>[K]>;
|
|
52
|
-
} : NonNullable<T>;
|
|
53
|
-
/**
|
|
54
|
-
* The form store type, combining form state with validation and submission handling.
|
|
55
|
-
*/
|
|
56
|
-
type FormStore<T extends FieldValues> = FormState<T> & {
|
|
57
|
-
/** Clears all validation errors from the form. */
|
|
58
|
-
clearErrors(): void;
|
|
59
|
-
/** Returns a form submit handler that validates and calls onSubmit with form values. */
|
|
60
|
-
handleSubmit(onSubmit: (values: T) => void): (e: React.SyntheticEvent) => void;
|
|
61
|
-
};
|
|
62
|
-
type NoEmptyValidator = "not-empty";
|
|
63
|
-
type RegexValidator = RegExp;
|
|
64
|
-
type FunctionValidator<T extends FieldValues> = (value: FieldPathValue<T, FieldPath<T>>, state: FormStore<T>) => string | undefined;
|
|
65
|
-
type Validator<T extends FieldValues> = NoEmptyValidator | RegexValidator | FunctionValidator<T>;
|
|
66
|
-
type FieldConfig<T extends FieldValues> = {
|
|
67
|
-
validate?: Validator<T>;
|
|
68
|
-
};
|
|
69
|
-
type CreateFormOptions<T extends FieldValues> = Partial<Record<FieldPath<T>, FieldConfig<T>>>;
|
|
70
|
-
type UnsubscribeFn = () => void;
|
|
71
|
-
type UnsubscribeFns = UnsubscribeFn[];
|
|
72
|
-
/**
|
|
73
|
-
* React hook that creates a form store with validation support.
|
|
74
|
-
*
|
|
75
|
-
* The form store extends the memory store with error handling and validation.
|
|
76
|
-
* Fields can be configured with validators that run on every change.
|
|
77
|
-
*
|
|
78
|
-
* @param defaultValue - Initial form values
|
|
79
|
-
* @param fieldConfigs - Optional validation configuration per field
|
|
80
|
-
* @returns A form store with validation and submission handling
|
|
81
|
-
*
|
|
82
|
-
* @example
|
|
83
|
-
* const form = useForm(
|
|
84
|
-
* { email: '', password: '' },
|
|
85
|
-
* {
|
|
86
|
-
* email: { validate: 'not-empty' },
|
|
87
|
-
* password: { validate: v => v && v.length < 8 ? 'Too short' : undefined }
|
|
88
|
-
* }
|
|
89
|
-
* )
|
|
90
|
-
*
|
|
91
|
-
* <form onSubmit={form.handleSubmit(values => console.log(values))}>
|
|
92
|
-
* <input value={form.email.use() ?? ''} onChange={e => form.email.set(e.target.value)} />
|
|
93
|
-
* {form.email.useError() && <span>{form.email.error}</span>}
|
|
94
|
-
* </form>
|
|
95
|
-
*/
|
|
96
|
-
declare function useForm<T extends FieldValues>(defaultValue: T, fieldConfigs?: CreateFormOptions<T>): FormStore<T>;
|
|
97
|
-
declare function createForm<T extends FieldValues>(namespace: string, defaultValue: T, fieldConfigs?: CreateFormOptions<T>): [FormStore<T>, UnsubscribeFns];
|
package/dist/src/form.js
DELETED
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
/** biome-ignore-all lint/suspicious/noExplicitAny: intended */
|
|
2
|
-
"use client";
|
|
3
|
-
import { pascalCase } from "change-case";
|
|
4
|
-
import { useEffect, useId, useMemo } from "react";
|
|
5
|
-
import { getSnapshot, produce } from "./impl";
|
|
6
|
-
import { createNode } from "./node";
|
|
7
|
-
import { createStoreRoot } from "./root";
|
|
8
|
-
export { createForm, useForm, };
|
|
9
|
-
/**
|
|
10
|
-
* React hook that creates a form store with validation support.
|
|
11
|
-
*
|
|
12
|
-
* The form store extends the memory store with error handling and validation.
|
|
13
|
-
* Fields can be configured with validators that run on every change.
|
|
14
|
-
*
|
|
15
|
-
* @param defaultValue - Initial form values
|
|
16
|
-
* @param fieldConfigs - Optional validation configuration per field
|
|
17
|
-
* @returns A form store with validation and submission handling
|
|
18
|
-
*
|
|
19
|
-
* @example
|
|
20
|
-
* const form = useForm(
|
|
21
|
-
* { email: '', password: '' },
|
|
22
|
-
* {
|
|
23
|
-
* email: { validate: 'not-empty' },
|
|
24
|
-
* password: { validate: v => v && v.length < 8 ? 'Too short' : undefined }
|
|
25
|
-
* }
|
|
26
|
-
* )
|
|
27
|
-
*
|
|
28
|
-
* <form onSubmit={form.handleSubmit(values => console.log(values))}>
|
|
29
|
-
* <input value={form.email.use() ?? ''} onChange={e => form.email.set(e.target.value)} />
|
|
30
|
-
* {form.email.useError() && <span>{form.email.error}</span>}
|
|
31
|
-
* </form>
|
|
32
|
-
*/
|
|
33
|
-
function useForm(defaultValue, fieldConfigs = {}) {
|
|
34
|
-
const formId = useId();
|
|
35
|
-
const namespace = `form:${formId}`;
|
|
36
|
-
const [form, unsubscribeFns] = useMemo(() => createForm(namespace, defaultValue, fieldConfigs), [namespace, defaultValue, fieldConfigs]);
|
|
37
|
-
useEffect(() => {
|
|
38
|
-
return () => {
|
|
39
|
-
for (const unsubscribe of unsubscribeFns) {
|
|
40
|
-
unsubscribe();
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
|
-
}, [unsubscribeFns]);
|
|
44
|
-
return form;
|
|
45
|
-
}
|
|
46
|
-
function createForm(namespace, defaultValue, fieldConfigs = {}) {
|
|
47
|
-
const errorNamespace = `_juststore_form_errors.${namespace}`;
|
|
48
|
-
const errorStore = createStoreRoot(errorNamespace, {}, { memoryOnly: true });
|
|
49
|
-
const storeApi = createStoreRoot(namespace, defaultValue, {
|
|
50
|
-
memoryOnly: true,
|
|
51
|
-
});
|
|
52
|
-
const formApi = {
|
|
53
|
-
clearErrors: () => produce(errorNamespace, undefined, false, true),
|
|
54
|
-
handleSubmit: (onSubmit) => (e) => {
|
|
55
|
-
e.preventDefault();
|
|
56
|
-
// disable submit if there are errors
|
|
57
|
-
if (Object.keys(getSnapshot(errorNamespace, true) ?? {}).length === 0) {
|
|
58
|
-
onSubmit(getSnapshot(namespace, true));
|
|
59
|
-
}
|
|
60
|
-
},
|
|
61
|
-
};
|
|
62
|
-
const store = createFormProxy(storeApi, errorStore);
|
|
63
|
-
const proxy = new Proxy(formApi, {
|
|
64
|
-
get(target, prop) {
|
|
65
|
-
if (prop in target) {
|
|
66
|
-
return target[prop];
|
|
67
|
-
}
|
|
68
|
-
return store[prop];
|
|
69
|
-
},
|
|
70
|
-
});
|
|
71
|
-
const unsubscribeFns = [];
|
|
72
|
-
for (const entry of Object.entries(fieldConfigs)) {
|
|
73
|
-
const [path, config] = entry;
|
|
74
|
-
const validator = getValidator(path, config?.validate);
|
|
75
|
-
if (validator) {
|
|
76
|
-
const unsubscribe = storeApi.subscribe(path, (value) => {
|
|
77
|
-
const error = validator(value, store);
|
|
78
|
-
if (!error) {
|
|
79
|
-
errorStore.reset(path);
|
|
80
|
-
}
|
|
81
|
-
else {
|
|
82
|
-
errorStore.set(path, error);
|
|
83
|
-
}
|
|
84
|
-
});
|
|
85
|
-
unsubscribeFns.push(unsubscribe);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return [proxy, unsubscribeFns];
|
|
89
|
-
}
|
|
90
|
-
/**
|
|
91
|
-
* Creates a form proxy node that extends the base node with error handling.
|
|
92
|
-
*
|
|
93
|
-
* @param storeApi - The form's value store
|
|
94
|
-
* @param errorStore - The form's error store
|
|
95
|
-
* @param path - The field path
|
|
96
|
-
* @returns A proxy with both state methods and error methods
|
|
97
|
-
*/
|
|
98
|
-
function createFormProxy(storeApi, errorStore) {
|
|
99
|
-
const proxyCache = new Map();
|
|
100
|
-
return createNode(storeApi, "", proxyCache, {
|
|
101
|
-
useError: {
|
|
102
|
-
get: (path) => () => errorStore.use(path),
|
|
103
|
-
},
|
|
104
|
-
error: {
|
|
105
|
-
get: (path) => errorStore.value(path),
|
|
106
|
-
},
|
|
107
|
-
setError: {
|
|
108
|
-
get: (path) => (error) => {
|
|
109
|
-
errorStore.set(path, error, false);
|
|
110
|
-
return true;
|
|
111
|
-
},
|
|
112
|
-
},
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
/**
|
|
116
|
-
* Converts a validator configuration into a validation function.
|
|
117
|
-
*
|
|
118
|
-
* @param field - The field path (used for error messages)
|
|
119
|
-
* @param validator - The validator config ('not-empty', RegExp, or function)
|
|
120
|
-
* @returns A validation function, or undefined if no validator provided
|
|
121
|
-
*/
|
|
122
|
-
function getValidator(field, validator) {
|
|
123
|
-
if (!validator) {
|
|
124
|
-
return undefined;
|
|
125
|
-
}
|
|
126
|
-
if (validator === "not-empty") {
|
|
127
|
-
return (value) => validateNoEmpty(field, value);
|
|
128
|
-
}
|
|
129
|
-
if (validator instanceof RegExp) {
|
|
130
|
-
return (value) => validateRegex(field, value, validator);
|
|
131
|
-
}
|
|
132
|
-
return validator;
|
|
133
|
-
}
|
|
134
|
-
/**
|
|
135
|
-
* Validates that a field has a non-empty value.
|
|
136
|
-
*
|
|
137
|
-
* @param field - The field path (used for error message)
|
|
138
|
-
* @param value - The value to validate
|
|
139
|
-
* @returns Error message if empty, undefined if valid
|
|
140
|
-
*/
|
|
141
|
-
function validateNoEmpty(field, value) {
|
|
142
|
-
if (!stringValue(value)) {
|
|
143
|
-
return `${pascalCase(field)} is required`;
|
|
144
|
-
}
|
|
145
|
-
return undefined;
|
|
146
|
-
}
|
|
147
|
-
/**
|
|
148
|
-
* Validates that a field matches a regular expression.
|
|
149
|
-
*
|
|
150
|
-
* @param field - The field path (used for error message)
|
|
151
|
-
* @param value - The value to validate
|
|
152
|
-
* @param regex - The pattern to match against
|
|
153
|
-
* @returns Error message if invalid, undefined if valid
|
|
154
|
-
*/
|
|
155
|
-
function validateRegex(field, value, regex) {
|
|
156
|
-
if (!regex.test(stringValue(value))) {
|
|
157
|
-
return `${pascalCase(field)} is invalid`;
|
|
158
|
-
}
|
|
159
|
-
return undefined;
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* Converts a value to a string for validation purposes.
|
|
163
|
-
* Returns empty string for non-primitive values.
|
|
164
|
-
*/
|
|
165
|
-
function stringValue(v) {
|
|
166
|
-
if (typeof v === "string") {
|
|
167
|
-
return v;
|
|
168
|
-
}
|
|
169
|
-
if (typeof v === "number") {
|
|
170
|
-
return String(v);
|
|
171
|
-
}
|
|
172
|
-
if (typeof v === "boolean") {
|
|
173
|
-
return String(v);
|
|
174
|
-
}
|
|
175
|
-
return "";
|
|
176
|
-
}
|