@shivaedev/form 0.13.0 → 0.14.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 +21 -0
- package/README.md +7 -1
- 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/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/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/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/persistence.ts +97 -63
- package/src/adapters/react.ts +3 -1
- package/src/draft.ts +70 -62
- package/src/draftValues.ts +65 -0
- package/src/form.ts +12 -6
- package/src/persistence.ts +17 -7
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/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
|
}
|