@shivaedev/form 0.13.1 → 0.15.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 +22 -0
- package/README.md +83 -10
- package/dist/adapters/browserDraftStorage.d.ts.map +1 -1
- package/dist/adapters/browserDraftStorage.js +27 -2
- package/dist/adapters/browserDraftStorage.js.map +1 -1
- package/dist/adapters/draftCodec.d.ts +7 -0
- package/dist/adapters/draftCodec.d.ts.map +1 -0
- package/dist/adapters/draftCodec.js +40 -0
- package/dist/adapters/draftCodec.js.map +1 -0
- package/dist/adapters/numberInput.d.ts +15 -0
- package/dist/adapters/numberInput.d.ts.map +1 -0
- package/dist/adapters/numberInput.js +11 -0
- package/dist/adapters/numberInput.js.map +1 -0
- package/dist/adapters/persistence.d.ts +1 -1
- package/dist/adapters/persistence.d.ts.map +1 -1
- package/dist/adapters/persistence.js +91 -61
- package/dist/adapters/persistence.js.map +1 -1
- package/dist/adapters/react.d.ts.map +1 -1
- package/dist/adapters/react.js +3 -1
- package/dist/adapters/react.js.map +1 -1
- package/dist/adapters/useList.d.ts +23 -0
- package/dist/adapters/useList.d.ts.map +1 -0
- package/dist/adapters/useList.js +27 -0
- package/dist/adapters/useList.js.map +1 -0
- package/dist/draft.d.ts +2 -2
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +67 -45
- package/dist/draft.js.map +1 -1
- package/dist/draftValues.d.ts +13 -0
- package/dist/draftValues.d.ts.map +1 -0
- package/dist/draftValues.js +47 -0
- package/dist/draftValues.js.map +1 -0
- package/dist/field.d.ts +17 -0
- package/dist/field.d.ts.map +1 -0
- package/dist/field.js +20 -0
- package/dist/field.js.map +1 -0
- package/dist/form.d.ts.map +1 -1
- package/dist/form.js +17 -9
- package/dist/form.js.map +1 -1
- package/dist/persistence.d.ts +15 -11
- package/dist/persistence.d.ts.map +1 -1
- package/dist/persistence.js.map +1 -1
- package/package.json +4 -4
- package/src/adapters/browserDraftStorage.ts +30 -2
- package/src/adapters/draftCodec.ts +43 -0
- package/src/adapters/numberInput.ts +17 -0
- package/src/adapters/persistence.ts +97 -63
- package/src/adapters/react.ts +3 -1
- package/src/adapters/useList.ts +39 -0
- package/src/draft.ts +70 -62
- package/src/draftValues.ts +65 -0
- package/src/field.ts +34 -0
- package/src/form.ts +12 -6
- package/src/persistence.ts +17 -7
|
@@ -1,87 +1,121 @@
|
|
|
1
|
-
import { Schema,
|
|
2
|
-
import type { PersistedDraftConfig, Persistence } from "#persistence.ts";
|
|
1
|
+
import { Data, Effect, type Schema, Semaphore } from "effect";
|
|
2
|
+
import type { DraftOperation, PersistedDraftConfig, Persistence } from "#persistence.ts";
|
|
3
|
+
import { draftCodec } from "./draftCodec.ts";
|
|
4
|
+
|
|
5
|
+
class DraftStorageUnavailable extends Data.TaggedError("DraftStorageUnavailable")<{ readonly cause: unknown }> {}
|
|
6
|
+
|
|
7
|
+
function operation<A>(run: () => DraftOperation<A>): Effect.Effect<A, DraftStorageUnavailable> {
|
|
8
|
+
return Effect.suspend(() => {
|
|
9
|
+
const value = run();
|
|
10
|
+
return Effect.isEffect(value) ? value : Effect.succeed(value);
|
|
11
|
+
}).pipe(Effect.catchCause((cause) => Effect.fail(new DraftStorageUnavailable({ cause }))));
|
|
12
|
+
}
|
|
3
13
|
|
|
4
14
|
export function persistence<TSchema extends Schema.Constraint>(schema: TSchema, config: PersistedDraftConfig): Persistence<TSchema["Encoded"]> {
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
edited: Schema.Array(Schema.String),
|
|
9
|
-
session: Schema.String,
|
|
10
|
-
updatedAt: Schema.Number,
|
|
11
|
-
values: Schema.Unknown,
|
|
12
|
-
version: Schema.Literal(1),
|
|
13
|
-
});
|
|
14
|
-
const valid = Schema.is(StoredDraft);
|
|
15
|
-
const decode = SchemaParser.decodeUnknownSync(encoded, { disableChecks: true });
|
|
16
|
-
const maxAgeMs = config.maxAgeMs ?? 7 * 24 * 60 * 60 * 1000;
|
|
17
|
-
const maxBytes = config.maxBytes ?? 256 * 1024;
|
|
18
|
-
function bytes(value: string) {
|
|
19
|
-
return new TextEncoder().encode(value).byteLength;
|
|
20
|
-
}
|
|
21
|
-
const storage = config.storage;
|
|
22
|
-
const session = config.session?.id ?? "";
|
|
15
|
+
const codec = draftCodec(schema, config);
|
|
16
|
+
const serial = Semaphore.makeUnsafe(1);
|
|
17
|
+
let revision: { value: string | undefined } = { value: undefined };
|
|
23
18
|
function active() {
|
|
24
19
|
return config.session?.active !== false;
|
|
25
20
|
}
|
|
26
|
-
function
|
|
21
|
+
function run(effect: Effect.Effect<unknown, DraftStorageUnavailable>) {
|
|
22
|
+
return Effect.runCallback(serial.withPermit(effect).pipe(Effect.catchTag("DraftStorageUnavailable", () => Effect.void)));
|
|
23
|
+
}
|
|
24
|
+
function clear(expected = revision.value) {
|
|
25
|
+
if (expected !== undefined) {
|
|
26
|
+
run(operation(() => config.storage().removeItem(config.key, expected)));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function read(raw: string | null) {
|
|
27
30
|
try {
|
|
28
|
-
return
|
|
31
|
+
return { values: raw === null ? undefined : codec.read(raw) };
|
|
29
32
|
} catch {
|
|
30
|
-
|
|
33
|
+
if (raw !== null) {
|
|
34
|
+
clear(raw);
|
|
35
|
+
}
|
|
31
36
|
return undefined;
|
|
32
37
|
}
|
|
33
38
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
config.session?.track(config.key, () =>
|
|
40
|
+
run(
|
|
41
|
+
Effect.gen(function* () {
|
|
42
|
+
const storage = yield* operation(config.storage);
|
|
43
|
+
const raw = yield* operation(() => storage.getItem(config.key));
|
|
44
|
+
if (raw !== null) {
|
|
45
|
+
yield* operation(() => codec.read(raw));
|
|
46
|
+
yield* operation(() => storage.removeItem(config.key, raw));
|
|
47
|
+
}
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
);
|
|
38
51
|
return {
|
|
39
|
-
clear: () => {
|
|
52
|
+
clear: (expected = revision.value) => {
|
|
40
53
|
if (active()) {
|
|
41
|
-
clear();
|
|
54
|
+
clear(expected);
|
|
42
55
|
}
|
|
43
56
|
},
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
57
|
+
observe: (receive) => {
|
|
58
|
+
let stopped = false;
|
|
59
|
+
let changes = 0;
|
|
60
|
+
function changed(raw: string | null, loaded = false) {
|
|
61
|
+
if (stopped || !active()) {
|
|
62
|
+
return;
|
|
48
63
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return undefined;
|
|
64
|
+
if (!loaded && revision.value === (raw ?? undefined)) {
|
|
65
|
+
return;
|
|
52
66
|
}
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
valid(value)
|
|
57
|
-
&& value.session === session
|
|
58
|
-
&& Number.isFinite(maxAgeMs)
|
|
59
|
-
&& Number.isFinite(maxBytes)
|
|
60
|
-
&& maxAgeMs > 0
|
|
61
|
-
&& maxBytes > 0
|
|
62
|
-
&& now - value.updatedAt <= maxAgeMs
|
|
63
|
-
&& value.updatedAt <= now
|
|
64
|
-
) {
|
|
65
|
-
return { baseline: decode(value.baseline), edited: value.edited, values: decode(value.values) };
|
|
67
|
+
const parsed = read(raw);
|
|
68
|
+
if (parsed && receive(parsed.values)) {
|
|
69
|
+
revision = { value: raw ?? undefined };
|
|
66
70
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
+
}
|
|
72
|
+
let unsubscribe: (() => void) | undefined;
|
|
73
|
+
const stop = Effect.runCallback(
|
|
74
|
+
Effect.gen(function* () {
|
|
75
|
+
if (!active()) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const storage = yield* operation(config.storage);
|
|
79
|
+
unsubscribe = yield* operation(() =>
|
|
80
|
+
storage.subscribe?.(config.key, (incoming) => {
|
|
81
|
+
changes += 1;
|
|
82
|
+
changed(incoming);
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
const before = changes;
|
|
86
|
+
const captured = revision;
|
|
87
|
+
const raw = yield* operation(() => storage.getItem(config.key));
|
|
88
|
+
if (before === changes && captured === revision) {
|
|
89
|
+
changed(raw, true);
|
|
90
|
+
}
|
|
91
|
+
}).pipe(Effect.catchTag("DraftStorageUnavailable", () => Effect.void)),
|
|
92
|
+
);
|
|
93
|
+
return () => {
|
|
94
|
+
stopped = true;
|
|
95
|
+
unsubscribe?.();
|
|
96
|
+
stop();
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
revision: () => {
|
|
100
|
+
const captured = revision;
|
|
101
|
+
return () => captured.value;
|
|
102
|
+
},
|
|
103
|
+
write: (baseline, values, edited, sameInput) => {
|
|
71
104
|
if (!active()) {
|
|
72
105
|
return;
|
|
73
106
|
}
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
|
|
107
|
+
try {
|
|
108
|
+
const expected = sameInput ? revision.value : undefined;
|
|
109
|
+
const raw = codec.write(baseline, values, edited);
|
|
110
|
+
if (sameInput) {
|
|
111
|
+
revision.value = raw;
|
|
112
|
+
} else {
|
|
113
|
+
revision = { value: raw };
|
|
80
114
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
115
|
+
run(operation(() => (active() ? config.storage().setItem(config.key, raw, expected) : undefined)));
|
|
116
|
+
} catch {
|
|
117
|
+
clear();
|
|
118
|
+
}
|
|
85
119
|
},
|
|
86
120
|
};
|
|
87
121
|
}
|
package/src/adapters/react.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useAtomRef, useAtomSet, useAtomValue } from "@effect/atom-react";
|
|
1
|
+
import { useAtomMount, useAtomRef, useAtomSet, useAtomValue } from "@effect/atom-react";
|
|
2
2
|
import type * as AsyncResult from "effect/reactivity/AsyncResult";
|
|
3
3
|
import { createContext, useCallback, useContext, useMemo } from "react";
|
|
4
4
|
import type { DraftSession } from "#draftSession.ts";
|
|
@@ -23,6 +23,7 @@ export function useField<TFields extends Fields, A, E, TRuntimeError, TKey exten
|
|
|
23
23
|
form: Form<TFields, A, E, TRuntimeError>,
|
|
24
24
|
name: TKey,
|
|
25
25
|
): Field<TFields, TKey> {
|
|
26
|
+
useAtomMount(form.dirty);
|
|
26
27
|
const value = useAtomRef(form.field(name));
|
|
27
28
|
const error = useAtomValue(form.error(name));
|
|
28
29
|
const onChange = useCallback((next: Encoded<TFields>[TKey]) => form.change(name, next), [form, name]);
|
|
@@ -38,6 +39,7 @@ export interface Submit<A, E> {
|
|
|
38
39
|
export function useSubmit<TFields extends Fields, A, E, TRuntimeError>(
|
|
39
40
|
form: Form<TFields, A, E, TRuntimeError>,
|
|
40
41
|
): Submit<A, E | TRuntimeError | FieldFailure | Invalid> {
|
|
42
|
+
useAtomMount(form.dirty);
|
|
41
43
|
const result = useAtomValue(form.submit);
|
|
42
44
|
const set = useAtomSet(form.submit);
|
|
43
45
|
const run = useCallback(() => set(), [set]);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useAtomMount, useAtomRef, useAtomValue } from "@effect/atom-react";
|
|
2
|
+
import * as Atom from "effect/reactivity/Atom";
|
|
3
|
+
import type * as AtomRef from "effect/reactivity/AtomRef";
|
|
4
|
+
import { useMemo } from "react";
|
|
5
|
+
|
|
6
|
+
interface ListForm<TKey extends string, TItem> {
|
|
7
|
+
readonly blur: (name: TKey) => void;
|
|
8
|
+
readonly change: (name: TKey, value: readonly TItem[]) => void;
|
|
9
|
+
readonly dirty: Atom.Atom<boolean>;
|
|
10
|
+
readonly error: (name: TKey) => Atom.Atom<string | undefined>;
|
|
11
|
+
readonly errorAt: (path: readonly [TKey, ...PropertyKey[]]) => Atom.Atom<string | undefined>;
|
|
12
|
+
readonly field: (name: TKey) => AtomRef.ReadonlyRef<readonly TItem[]>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useList<TKey extends string, TItem>(form: ListForm<NoInfer<TKey>, TItem>, name: TKey) {
|
|
16
|
+
useAtomMount(form.dirty);
|
|
17
|
+
const values = useAtomRef(form.field(name));
|
|
18
|
+
const error = useAtomValue(form.error(name));
|
|
19
|
+
const errors = useAtomValue(
|
|
20
|
+
useMemo(() => Atom.readable((get) => values.map((_, index) => get(form.errorAt([name, index])))), [form, name, values]),
|
|
21
|
+
);
|
|
22
|
+
function change(next: readonly TItem[]) {
|
|
23
|
+
form.change(name, next);
|
|
24
|
+
}
|
|
25
|
+
function current() {
|
|
26
|
+
return form.field(name).value;
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
add: (value: TItem) => change([...current(), value]),
|
|
30
|
+
error,
|
|
31
|
+
items: values.map((value, index) => ({
|
|
32
|
+
error: errors[index],
|
|
33
|
+
onChange: (next: TItem) => change(current().map((item, at) => (at === index ? next : item))),
|
|
34
|
+
onRemove: () => change(current().filter((_, at) => at !== index)),
|
|
35
|
+
value,
|
|
36
|
+
})),
|
|
37
|
+
onBlur: () => form.blur(name),
|
|
38
|
+
};
|
|
39
|
+
}
|
package/src/draft.ts
CHANGED
|
@@ -1,63 +1,66 @@
|
|
|
1
|
-
import { Equal } from "effect";
|
|
2
1
|
import * as Atom from "effect/reactivity/Atom";
|
|
3
2
|
import * as AtomRef from "effect/reactivity/AtomRef";
|
|
4
|
-
import type
|
|
5
|
-
|
|
6
|
-
type Entries = Readonly<Record<string, unknown>>;
|
|
7
|
-
|
|
8
|
-
interface Draft<TValues> {
|
|
9
|
-
readonly baseline: TValues;
|
|
10
|
-
readonly current: TValues;
|
|
11
|
-
readonly settled: Entries;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const none: Entries = {};
|
|
15
|
-
|
|
16
|
-
function untouched({ baseline, current, settled }: Draft<Entries>, name: string): boolean {
|
|
17
|
-
return Equal.equals(current[name], baseline[name]) || (Object.hasOwn(settled, name) && Equal.equals(current[name], settled[name]));
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function changed(held: Draft<Entries>): boolean {
|
|
21
|
-
return Object.keys({ ...held.baseline, ...held.current }).some((name) => !untouched(held, name));
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function merged<TValues>(held: Draft<TValues>, incoming: TValues): TValues;
|
|
25
|
-
function merged(held: Draft<Entries>, incoming: Entries): unknown {
|
|
26
|
-
const result: Record<string, unknown> = {};
|
|
27
|
-
for (const name of Object.keys({ ...held.current, ...incoming })) {
|
|
28
|
-
const source = untouched(held, name) ? incoming : held.current;
|
|
29
|
-
if (Object.hasOwn(source, name)) {
|
|
30
|
-
result[name] = source[name];
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
return result;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function matching(current: Entries, submitted: Entries): Entries {
|
|
37
|
-
return Object.fromEntries(
|
|
38
|
-
Object.keys({ ...current, ...submitted })
|
|
39
|
-
.filter((name) => Equal.equals(current[name], submitted[name]))
|
|
40
|
-
.map((name) => [name, submitted[name]]),
|
|
41
|
-
);
|
|
42
|
-
}
|
|
3
|
+
import { accepted, changed, changedFields, type Draft, type Entries, merged, mergeObserved, untouched } from "./draftValues.ts";
|
|
4
|
+
import type { Persistence, StoredValues } from "./persistence.ts";
|
|
43
5
|
|
|
44
6
|
export function draft<TValues extends Entries>(initial: TValues, persistence?: Persistence<TValues>) {
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
7
|
+
const none: Entries = {};
|
|
8
|
+
const state = AtomRef.make<Draft<TValues>>({ baseline: initial, current: initial, settled: none });
|
|
9
|
+
const local = new Set<string>();
|
|
10
|
+
let previous = initial;
|
|
11
|
+
let receiving = false;
|
|
12
|
+
let refreshing = false;
|
|
13
|
+
function withoutWriting(run: () => void) {
|
|
14
|
+
receiving = true;
|
|
15
|
+
run();
|
|
16
|
+
receiving = false;
|
|
17
|
+
}
|
|
18
|
+
function restore(restored: StoredValues<TValues> | undefined) {
|
|
19
|
+
const held = state.value;
|
|
20
|
+
const incoming = restored
|
|
21
|
+
? merged(
|
|
22
|
+
{
|
|
23
|
+
baseline: restored.baseline,
|
|
24
|
+
current: restored.values,
|
|
25
|
+
settled: Object.fromEntries(Object.entries(restored.values).filter(([name]) => !restored.edited.includes(name))),
|
|
26
|
+
},
|
|
27
|
+
held.baseline,
|
|
28
|
+
)
|
|
29
|
+
: held.baseline;
|
|
30
|
+
withoutWriting(() => state.update((value) => ({ ...value, current: mergeObserved(value.current, incoming, local) })));
|
|
31
|
+
return local.size === 0;
|
|
32
|
+
}
|
|
33
|
+
const mounted = Atom.readable((get) => {
|
|
34
|
+
if (persistence) {
|
|
35
|
+
get.addFinalizer(persistence.observe(restore));
|
|
36
|
+
}
|
|
55
37
|
});
|
|
38
|
+
function releaseCleanFields() {
|
|
39
|
+
for (const name of local) {
|
|
40
|
+
if (untouched(state.value, name)) {
|
|
41
|
+
local.delete(name);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function store(value: Draft<TValues>, sameInput = refreshing) {
|
|
46
|
+
const edited = Object.keys({ ...value.baseline, ...value.current }).filter((name) => !untouched(value, name));
|
|
47
|
+
persistence?.write({ ...value.baseline, ...value.settled }, value.current, edited, sameInput);
|
|
48
|
+
}
|
|
56
49
|
if (persistence) {
|
|
57
50
|
state.subscribe((value) => {
|
|
51
|
+
const before = previous;
|
|
52
|
+
previous = value.current;
|
|
53
|
+
if (receiving) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!refreshing) {
|
|
57
|
+
for (const name of changedFields(before, value.current)) {
|
|
58
|
+
local.add(name);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
releaseCleanFields();
|
|
58
62
|
if (changed(value)) {
|
|
59
|
-
|
|
60
|
-
persistence.write({ ...value.baseline, ...value.settled }, value.current, edited);
|
|
63
|
+
store(value);
|
|
61
64
|
} else {
|
|
62
65
|
persistence.clear();
|
|
63
66
|
}
|
|
@@ -65,15 +68,20 @@ export function draft<TValues extends Entries>(initial: TValues, persistence?: P
|
|
|
65
68
|
}
|
|
66
69
|
return {
|
|
67
70
|
dirty: Atom.readable((get) => {
|
|
71
|
+
get(mounted);
|
|
68
72
|
get.addFinalizer(state.subscribe((value) => get.setSelf(changed(value))));
|
|
69
73
|
return changed(state.value);
|
|
70
74
|
}),
|
|
75
|
+
mounted,
|
|
71
76
|
receive: (incoming: TValues): void => {
|
|
77
|
+
refreshing = true;
|
|
72
78
|
state.update((held) => ({
|
|
73
79
|
baseline: incoming,
|
|
74
80
|
current: changed(held) ? merged(held, incoming) : incoming,
|
|
75
81
|
settled: none,
|
|
76
82
|
}));
|
|
83
|
+
refreshing = false;
|
|
84
|
+
releaseCleanFields();
|
|
77
85
|
},
|
|
78
86
|
revert: (): void => {
|
|
79
87
|
state.update((held) => ({
|
|
@@ -84,19 +92,19 @@ export function draft<TValues extends Entries>(initial: TValues, persistence?: P
|
|
|
84
92
|
},
|
|
85
93
|
submission: () => {
|
|
86
94
|
const { baseline, current: submitted } = state.value;
|
|
95
|
+
const revision = persistence?.revision();
|
|
87
96
|
return {
|
|
88
97
|
accept: (incoming?: TValues): void => {
|
|
89
|
-
state.update((held) =>
|
|
90
|
-
|
|
91
|
-
|
|
98
|
+
withoutWriting(() => state.update((held) => accepted(held, baseline, submitted, incoming)));
|
|
99
|
+
releaseCleanFields();
|
|
100
|
+
if (changed(state.value)) {
|
|
101
|
+
store(state.value, true);
|
|
102
|
+
} else {
|
|
103
|
+
const expected = revision?.();
|
|
104
|
+
if (expected !== undefined) {
|
|
105
|
+
persistence?.clear(expected);
|
|
92
106
|
}
|
|
93
|
-
|
|
94
|
-
...held,
|
|
95
|
-
baseline: incoming ?? submitted,
|
|
96
|
-
current: incoming === undefined ? held.current : merged({ ...held, baseline: submitted, settled: none }, incoming),
|
|
97
|
-
settled: none,
|
|
98
|
-
};
|
|
99
|
-
});
|
|
107
|
+
}
|
|
100
108
|
},
|
|
101
109
|
values: submitted,
|
|
102
110
|
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Equal } from "effect";
|
|
2
|
+
|
|
3
|
+
export type Entries = Readonly<Record<string, unknown>>;
|
|
4
|
+
|
|
5
|
+
export interface Draft<TValues> {
|
|
6
|
+
readonly baseline: TValues;
|
|
7
|
+
readonly current: TValues;
|
|
8
|
+
readonly settled: Entries;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function untouched({ baseline, current, settled }: Draft<Entries>, name: string): boolean {
|
|
12
|
+
return Equal.equals(current[name], baseline[name]) || (Object.hasOwn(settled, name) && Equal.equals(current[name], settled[name]));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function changed(held: Draft<Entries>): boolean {
|
|
16
|
+
return Object.keys({ ...held.baseline, ...held.current }).some((name) => !untouched(held, name));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function merged<TValues>(held: Draft<TValues>, incoming: TValues): TValues;
|
|
20
|
+
export function merged(held: Draft<Entries>, incoming: Entries): unknown {
|
|
21
|
+
const result: Record<string, unknown> = {};
|
|
22
|
+
for (const name of Object.keys({ ...held.current, ...incoming })) {
|
|
23
|
+
const source = untouched(held, name) ? incoming : held.current;
|
|
24
|
+
if (Object.hasOwn(source, name)) {
|
|
25
|
+
result[name] = source[name];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function matching(current: Entries, submitted: Entries): Entries {
|
|
32
|
+
return Object.fromEntries(
|
|
33
|
+
Object.keys({ ...current, ...submitted })
|
|
34
|
+
.filter((name) => Equal.equals(current[name], submitted[name]))
|
|
35
|
+
.map((name) => [name, submitted[name]]),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function mergeObserved<TValues>(current: TValues, incoming: TValues, local: ReadonlySet<string>): TValues;
|
|
40
|
+
export function mergeObserved(current: Entries, incoming: Entries, local: ReadonlySet<string>): unknown {
|
|
41
|
+
const result: Record<string, unknown> = {};
|
|
42
|
+
for (const name of Object.keys({ ...current, ...incoming })) {
|
|
43
|
+
const source = local.has(name) ? current : incoming;
|
|
44
|
+
if (Object.hasOwn(source, name)) {
|
|
45
|
+
result[name] = source[name];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function changedFields(previous: Entries, current: Entries): readonly string[] {
|
|
52
|
+
return Object.keys({ ...previous, ...current }).filter((name) => !Equal.equals(previous[name], current[name]));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function accepted<TValues extends Entries>(held: Draft<TValues>, baseline: TValues, submitted: TValues, incoming?: TValues): Draft<TValues> {
|
|
56
|
+
if (held.baseline !== baseline) {
|
|
57
|
+
return { ...held, settled: matching(held.current, submitted) };
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
...held,
|
|
61
|
+
baseline: incoming ?? submitted,
|
|
62
|
+
current: incoming === undefined ? held.current : merged({ ...held, baseline: submitted, settled: {} }, incoming),
|
|
63
|
+
settled: {},
|
|
64
|
+
};
|
|
65
|
+
}
|
package/src/field.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
import { emptyAsNull } from "./emptyAsNull.ts";
|
|
3
|
+
|
|
4
|
+
export interface NumberOptions {
|
|
5
|
+
readonly integer?: boolean;
|
|
6
|
+
readonly max?: number;
|
|
7
|
+
readonly min?: number;
|
|
8
|
+
readonly optional?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export function number(options?: NumberOptions & { readonly optional?: false }): Schema.Codec<number, string>;
|
|
11
|
+
export function number(options: NumberOptions): Schema.Codec<number | null, string>;
|
|
12
|
+
export function number(options: NumberOptions = {}): Schema.Codec<number | null, string> {
|
|
13
|
+
let populated = Schema.NonEmptyString.pipe(Schema.decodeTo(Schema.FiniteFromString));
|
|
14
|
+
if (options.min !== undefined) {
|
|
15
|
+
populated = populated.check(Schema.isGreaterThanOrEqualTo(options.min));
|
|
16
|
+
}
|
|
17
|
+
if (options.max !== undefined) {
|
|
18
|
+
populated = populated.check(Schema.isLessThanOrEqualTo(options.max));
|
|
19
|
+
}
|
|
20
|
+
if (options.integer) {
|
|
21
|
+
populated = populated.check(Schema.isInt());
|
|
22
|
+
}
|
|
23
|
+
return Schema.Trim.pipe(Schema.decodeTo(options.optional ? emptyAsNull(populated) : populated));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ListOptions {
|
|
27
|
+
readonly max: number;
|
|
28
|
+
readonly min?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function list<TItem extends Schema.Constraint>(item: TItem, options: ListOptions) {
|
|
32
|
+
const values = Schema.Array(item).check(Schema.isMaxLength(options.max));
|
|
33
|
+
return options.min === undefined ? values : values.check(Schema.isMinLength(options.min));
|
|
34
|
+
}
|
package/src/form.ts
CHANGED
|
@@ -49,7 +49,11 @@ export function form<TFields extends Fields, TValue, TError, TServices = never,
|
|
|
49
49
|
return made;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
const
|
|
52
|
+
const fromValues = fromRef(values);
|
|
53
|
+
const valuesAtom = Atom.readable((get) => {
|
|
54
|
+
get(editing.mounted);
|
|
55
|
+
return get(fromValues);
|
|
56
|
+
});
|
|
53
57
|
|
|
54
58
|
const decoded = runtime.atom((get) =>
|
|
55
59
|
decode(get(valuesAtom)).pipe(
|
|
@@ -77,13 +81,15 @@ export function form<TFields extends Fields, TValue, TError, TServices = never,
|
|
|
77
81
|
fail: (path, message) => Effect.fail(new FieldFailure(typeof path === "string" ? { message, path } : { message, path: path[0], segments: path })),
|
|
78
82
|
};
|
|
79
83
|
|
|
80
|
-
const validate = runtime.fn<void>()(() => {
|
|
84
|
+
const validate = runtime.fn<void>()((_, get) => {
|
|
85
|
+
get.mount(editing.mounted);
|
|
81
86
|
status.attempt();
|
|
82
87
|
return snapshot(values.value);
|
|
83
88
|
});
|
|
84
89
|
|
|
85
|
-
const submit = runtime.fn<void>()(() =>
|
|
86
|
-
|
|
90
|
+
const submit = runtime.fn<void>()((_, get) => {
|
|
91
|
+
get.mount(editing.mounted);
|
|
92
|
+
return Effect.gen(function* () {
|
|
87
93
|
status.attempt();
|
|
88
94
|
const submission = editing.submission();
|
|
89
95
|
const submitted = submission.values;
|
|
@@ -92,8 +98,8 @@ export function form<TFields extends Fields, TValue, TError, TServices = never,
|
|
|
92
98
|
Effect.tapError(noted(submitted)),
|
|
93
99
|
Effect.tap((saved) => Effect.sync(() => submission.accept(config.savedValues?.(saved)))),
|
|
94
100
|
);
|
|
95
|
-
})
|
|
96
|
-
);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
97
103
|
|
|
98
104
|
return {
|
|
99
105
|
blur: status.touch,
|
package/src/persistence.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { Context } from "effect";
|
|
1
|
+
import { Context, type Effect } from "effect";
|
|
2
2
|
import type { DraftSession } from "./draftSession.ts";
|
|
3
3
|
|
|
4
|
+
export type DraftOperation<A> = A | Effect.Effect<A, unknown>;
|
|
5
|
+
|
|
4
6
|
export interface DraftStorage {
|
|
5
|
-
readonly getItem: (key: string) => string | null
|
|
6
|
-
readonly removeItem: (key: string) => void
|
|
7
|
-
readonly setItem: (key: string, value: string) => void
|
|
7
|
+
readonly getItem: (key: string) => DraftOperation<string | null>;
|
|
8
|
+
readonly removeItem: (key: string, expected: string) => DraftOperation<void>;
|
|
9
|
+
readonly setItem: (key: string, value: string, expected?: string) => DraftOperation<void>;
|
|
10
|
+
readonly subscribe?: (key: string, listener: (value: string | null) => void) => () => void;
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
export class DraftStorageProvider extends Context.Service<DraftStorageProvider, () => DraftStorage>()("@shivaedev/form/DraftStorageProvider") {}
|
|
@@ -18,8 +21,15 @@ export interface PersistedDraftConfig {
|
|
|
18
21
|
readonly storage: () => DraftStorage;
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
export interface StoredValues<TValues> {
|
|
25
|
+
readonly baseline: TValues;
|
|
26
|
+
readonly edited: readonly string[];
|
|
27
|
+
readonly values: TValues;
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
export interface Persistence<TValues> {
|
|
22
|
-
readonly clear: () => void;
|
|
23
|
-
readonly
|
|
24
|
-
readonly
|
|
31
|
+
readonly clear: (revision?: string) => void;
|
|
32
|
+
readonly observe: (receive: (values: StoredValues<TValues> | undefined) => boolean) => () => void;
|
|
33
|
+
readonly revision: () => () => string | undefined;
|
|
34
|
+
readonly write: (baseline: TValues, values: TValues, edited: readonly string[], sameInput?: boolean) => void;
|
|
25
35
|
}
|