@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +7 -1
  3. package/dist/adapters/browserDraftStorage.d.ts.map +1 -1
  4. package/dist/adapters/browserDraftStorage.js +27 -2
  5. package/dist/adapters/browserDraftStorage.js.map +1 -1
  6. package/dist/adapters/draftCodec.d.ts +7 -0
  7. package/dist/adapters/draftCodec.d.ts.map +1 -0
  8. package/dist/adapters/draftCodec.js +40 -0
  9. package/dist/adapters/draftCodec.js.map +1 -0
  10. package/dist/adapters/persistence.d.ts +1 -1
  11. package/dist/adapters/persistence.d.ts.map +1 -1
  12. package/dist/adapters/persistence.js +91 -61
  13. package/dist/adapters/persistence.js.map +1 -1
  14. package/dist/adapters/react.d.ts.map +1 -1
  15. package/dist/adapters/react.js +3 -1
  16. package/dist/adapters/react.js.map +1 -1
  17. package/dist/draft.d.ts +2 -2
  18. package/dist/draft.d.ts.map +1 -1
  19. package/dist/draft.js +67 -45
  20. package/dist/draft.js.map +1 -1
  21. package/dist/draftValues.d.ts +13 -0
  22. package/dist/draftValues.d.ts.map +1 -0
  23. package/dist/draftValues.js +47 -0
  24. package/dist/draftValues.js.map +1 -0
  25. package/dist/form.d.ts.map +1 -1
  26. package/dist/form.js +17 -9
  27. package/dist/form.js.map +1 -1
  28. package/dist/persistence.d.ts +15 -11
  29. package/dist/persistence.d.ts.map +1 -1
  30. package/dist/persistence.js.map +1 -1
  31. package/package.json +4 -4
  32. package/src/adapters/browserDraftStorage.ts +30 -2
  33. package/src/adapters/draftCodec.ts +43 -0
  34. package/src/adapters/persistence.ts +97 -63
  35. package/src/adapters/react.ts +3 -1
  36. package/src/draft.ts +70 -62
  37. package/src/draftValues.ts +65 -0
  38. package/src/form.ts +12 -6
  39. 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 { Persistence } from "./persistence.ts";
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 restored = persistence?.read();
46
- const loaded = {
47
- baseline: restored?.baseline ?? initial,
48
- current: restored?.values ?? initial,
49
- settled: restored ? Object.fromEntries(Object.entries(restored.values).filter(([name]) => !restored.edited.includes(name))) : none,
50
- };
51
- const state = AtomRef.make<Draft<TValues>>({
52
- baseline: initial,
53
- current: restored ? merged(loaded, initial) : initial,
54
- settled: none,
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
- const edited = Object.keys({ ...value.baseline, ...value.current }).filter((name) => !untouched(value, name));
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
- if (held.baseline !== baseline) {
91
- return { ...held, settled: matching(held.current, submitted) };
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
- return {
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 valuesAtom = fromRef(values);
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
- Effect.gen(function* () {
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,
@@ -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 read: () => { readonly baseline: TValues; readonly edited: readonly string[]; readonly values: TValues } | undefined;
24
- readonly write: (baseline: TValues, values: TValues, edited: readonly string[]) => void;
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
  }