@shivaedev/form 0.10.0 → 0.12.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 (70) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +62 -22
  3. package/dist/adapters/browserDraftStorage.d.ts +4 -0
  4. package/dist/adapters/browserDraftStorage.d.ts.map +1 -0
  5. package/dist/adapters/browserDraftStorage.js +4 -0
  6. package/dist/adapters/browserDraftStorage.js.map +1 -0
  7. package/dist/adapters/draftPersistence.d.ts +7 -0
  8. package/dist/adapters/draftPersistence.d.ts.map +1 -0
  9. package/dist/adapters/draftPersistence.js +8 -0
  10. package/dist/adapters/draftPersistence.js.map +1 -0
  11. package/dist/adapters/persistence.d.ts +4 -0
  12. package/dist/adapters/persistence.d.ts.map +1 -0
  13. package/dist/adapters/persistence.js +84 -0
  14. package/dist/adapters/persistence.js.map +1 -0
  15. package/dist/{client → adapters}/react.d.ts +3 -6
  16. package/dist/adapters/react.d.ts.map +1 -0
  17. package/dist/{client → adapters}/react.js +1 -4
  18. package/dist/adapters/react.js.map +1 -0
  19. package/dist/checkFeedback.d.ts +6 -0
  20. package/dist/checkFeedback.d.ts.map +1 -0
  21. package/dist/checkFeedback.js +27 -0
  22. package/dist/checkFeedback.js.map +1 -0
  23. package/dist/draft.d.ts +1 -1
  24. package/dist/draft.d.ts.map +1 -1
  25. package/dist/draft.js +12 -2
  26. package/dist/draft.js.map +1 -1
  27. package/dist/fieldRejection.d.ts +22 -0
  28. package/dist/fieldRejection.d.ts.map +1 -0
  29. package/dist/fieldRejection.js +21 -0
  30. package/dist/fieldRejection.js.map +1 -0
  31. package/dist/form.d.ts.map +1 -1
  32. package/dist/form.js +20 -28
  33. package/dist/form.js.map +1 -1
  34. package/dist/messages.d.ts.map +1 -1
  35. package/dist/messages.js +9 -18
  36. package/dist/messages.js.map +1 -1
  37. package/dist/persistence.d.ts +5 -2
  38. package/dist/persistence.d.ts.map +1 -1
  39. package/dist/persistence.js +2 -82
  40. package/dist/persistence.js.map +1 -1
  41. package/dist/refs.d.ts.map +1 -1
  42. package/dist/refs.js +1 -2
  43. package/dist/refs.js.map +1 -1
  44. package/dist/shape.d.ts +8 -1
  45. package/dist/shape.d.ts.map +1 -1
  46. package/dist/shape.js.map +1 -1
  47. package/dist/statusOf.d.ts.map +1 -1
  48. package/dist/statusOf.js +1 -2
  49. package/dist/statusOf.js.map +1 -1
  50. package/dist/validation.d.ts +7 -0
  51. package/dist/validation.d.ts.map +1 -0
  52. package/dist/validation.js +11 -0
  53. package/dist/validation.js.map +1 -0
  54. package/package.json +7 -4
  55. package/src/adapters/browserDraftStorage.ts +4 -0
  56. package/src/adapters/draftPersistence.ts +8 -0
  57. package/src/adapters/persistence.ts +87 -0
  58. package/src/{client → adapters}/react.ts +5 -11
  59. package/src/checkFeedback.ts +34 -0
  60. package/src/draft.ts +12 -4
  61. package/src/fieldRejection.ts +47 -0
  62. package/src/form.ts +22 -42
  63. package/src/messages.ts +7 -16
  64. package/src/persistence.ts +3 -86
  65. package/src/refs.ts +3 -2
  66. package/src/shape.ts +9 -1
  67. package/src/statusOf.ts +1 -2
  68. package/src/validation.ts +18 -0
  69. package/dist/client/react.d.ts.map +0 -1
  70. package/dist/client/react.js.map +0 -1
@@ -0,0 +1,87 @@
1
+ import { Schema, SchemaParser } from "effect";
2
+ import type { PersistedDraftConfig, Persistence } from "#persistence.ts";
3
+
4
+ export function persistence<TSchema extends Schema.Constraint>(schema: TSchema, config: PersistedDraftConfig): Persistence<TSchema["Encoded"]> {
5
+ const encoded = Schema.toEncoded(schema);
6
+ const StoredDraft = Schema.Struct({
7
+ baseline: Schema.Unknown,
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 ?? "";
23
+ function active() {
24
+ return config.session?.active !== false;
25
+ }
26
+ function attempt<A>(run: () => A, failed?: () => void): A | undefined {
27
+ try {
28
+ return run();
29
+ } catch {
30
+ failed?.();
31
+ return undefined;
32
+ }
33
+ }
34
+ function clear() {
35
+ attempt(() => storage().removeItem(config.key));
36
+ }
37
+ config.session?.track(config.key, clear);
38
+ return {
39
+ clear: () => {
40
+ if (active()) {
41
+ clear();
42
+ }
43
+ },
44
+ read: () =>
45
+ attempt(() => {
46
+ if (!active()) {
47
+ return undefined;
48
+ }
49
+ const raw = storage().getItem(config.key);
50
+ if (raw === null) {
51
+ return undefined;
52
+ }
53
+ const value: unknown = bytes(raw) <= maxBytes ? JSON.parse(raw) : undefined;
54
+ const now = config.now();
55
+ if (
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) };
66
+ }
67
+ clear();
68
+ return undefined;
69
+ }, clear),
70
+ write: (baseline, values, edited) => {
71
+ if (!active()) {
72
+ return;
73
+ }
74
+ attempt(() => {
75
+ const raw = JSON.stringify({ baseline, edited, session, updatedAt: config.now(), values, version: 1 });
76
+ const parsed: unknown = JSON.parse(raw);
77
+ if (!(maxAgeMs > 0 && Number.isFinite(maxAgeMs) && Number.isFinite(maxBytes) && bytes(raw) <= maxBytes && valid(parsed))) {
78
+ clear();
79
+ return;
80
+ }
81
+ decode(parsed.baseline);
82
+ decode(parsed.values);
83
+ storage().setItem(config.key, raw);
84
+ }, clear);
85
+ },
86
+ };
87
+ }
@@ -2,20 +2,14 @@ import { 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";
5
- import type { DraftStorage, PersistedDraftConfig } from "#persistence.ts";
5
+ import type { PersistedDraftConfig } from "#persistence.ts";
6
6
  import type { Encoded, FieldFailure, Fields, Form, Invalid, Name } from "#shape.ts";
7
7
  export const DraftSessionContext = createContext<DraftSession | undefined>(undefined);
8
- export interface BrowserDraftConfig extends Omit<PersistedDraftConfig, "storage"> {
9
- readonly storage?: () => DraftStorage;
10
- }
11
- function browserStorage(): DraftStorage {
12
- return globalThis.localStorage;
13
- }
14
- export function usePersistedDraft(config: BrowserDraftConfig): PersistedDraftConfig;
15
- export function usePersistedDraft(config: BrowserDraftConfig | undefined): PersistedDraftConfig | undefined;
16
- export function usePersistedDraft(config: BrowserDraftConfig | undefined): PersistedDraftConfig | undefined {
8
+ export function usePersistedDraft(config: PersistedDraftConfig): PersistedDraftConfig;
9
+ export function usePersistedDraft(config: PersistedDraftConfig | undefined): PersistedDraftConfig | undefined;
10
+ export function usePersistedDraft(config: PersistedDraftConfig | undefined): PersistedDraftConfig | undefined {
17
11
  const session = useContext(DraftSessionContext);
18
- return config ? { ...config, session: session ?? config.session, storage: config.storage ?? browserStorage } : undefined;
12
+ return config ? { ...config, session: session ?? config.session } : undefined;
19
13
  }
20
14
  export interface Field<TFields extends Fields, TKey extends Name<TFields>> {
21
15
  readonly choices: readonly Encoded<TFields>[TKey][] | undefined;
@@ -0,0 +1,34 @@
1
+ import { type Duration, Effect, Equal } from "effect";
2
+ import * as AsyncResult from "effect/reactivity/AsyncResult";
3
+ import * as Atom from "effect/reactivity/Atom";
4
+ import type * as AtomRef from "effect/reactivity/AtomRef";
5
+ import { fromRef } from "./refs.ts";
6
+ import type { Encoded, Fields, Name, Services } from "./shape.ts";
7
+
8
+ export function checkFeedback<TFields extends Fields, R, E>(
9
+ runtime: Atom.AtomRuntime<Services<TFields, R>, E>,
10
+ checks: ReadonlyMap<string, (value: Encoded<TFields>[Name<TFields>]) => Effect.Effect<string | undefined, never, R>>,
11
+ refFor: <TKey extends Name<TFields>>(name: TKey) => AtomRef.AtomRef<Encoded<TFields>[TKey]>,
12
+ debounce: Duration.Input,
13
+ ) {
14
+ return Atom.family((name: Name<TFields>): Atom.Atom<string | undefined> => {
15
+ const check = checks.get(name);
16
+ if (check === undefined) {
17
+ return Atom.make((): string | undefined => undefined);
18
+ }
19
+ const current = fromRef(refFor(name));
20
+ const settled = Atom.debounce(current, debounce);
21
+ const answered = runtime.atom((get) => {
22
+ const input = get(settled);
23
+ return Effect.map(check(input), (message) => ({
24
+ input,
25
+ message,
26
+ }));
27
+ });
28
+ return Atom.readable((get): string | undefined => {
29
+ const result = get(answered);
30
+ const value = get(current);
31
+ return AsyncResult.isSuccess(result) && !result.waiting && Equal.equals(result.value.input, value) ? result.value.message : undefined;
32
+ });
33
+ });
34
+ }
package/src/draft.ts CHANGED
@@ -85,10 +85,18 @@ export function draft<TValues extends Entries>(initial: TValues, persistence?: P
85
85
  submission: () => {
86
86
  const { baseline, current: submitted } = state.value;
87
87
  return {
88
- accept: (): void => {
89
- state.update((held) =>
90
- held.baseline === baseline ? { ...held, baseline: submitted, settled: none } : { ...held, settled: matching(held.current, submitted) },
91
- );
88
+ accept: (incoming?: TValues): void => {
89
+ state.update((held) => {
90
+ if (held.baseline !== baseline) {
91
+ return { ...held, settled: matching(held.current, submitted) };
92
+ }
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
+ });
92
100
  },
93
101
  values: submitted,
94
102
  };
@@ -0,0 +1,47 @@
1
+ import { Effect, Option, type Schema } from "effect";
2
+ import { rejectedField } from "@shivaedev/errors/rejectedField.ts";
3
+ import type { FieldFailure, Fields, Name, Path, Submitter } from "#shape.ts";
4
+
5
+ export interface FieldRejection<TName extends string> {
6
+ readonly field: TName | Path<TName>;
7
+ readonly message: string;
8
+ }
9
+
10
+ export type RejectField<TFields extends Fields, TError> = (error: TError) => FieldRejection<Name<TFields>> | undefined;
11
+
12
+ type Unmatched<TFields extends Fields, TError> = TError extends { readonly _tag: string; readonly message: string }
13
+ ? "field" extends keyof TError
14
+ ? Exclude<TError["field"], Name<TFields> | undefined>
15
+ : never
16
+ : never;
17
+
18
+ export type FieldRejectionMapping<TFields extends Fields, TError> = { readonly rejectField?: RejectField<TFields, TError> } & ([
19
+ Unmatched<TFields, TError>,
20
+ ] extends [never]
21
+ ? unknown
22
+ : { readonly rejectField: RejectField<TFields, TError> });
23
+
24
+ export function fieldRejectionOf<TFields extends Fields>(schema: Schema.Struct<TFields>) {
25
+ return (error: unknown): FieldRejection<Name<TFields>> | undefined => {
26
+ function isName(name: string): name is Name<TFields> {
27
+ return Object.hasOwn(schema.fields, name);
28
+ }
29
+ return Option.match(rejectedField(error), {
30
+ onNone: () => undefined,
31
+ onSome: ({ field, message }) => (isName(field) ? { field, message } : undefined),
32
+ });
33
+ };
34
+ }
35
+
36
+ export function rejecting<TFields extends Fields, TValues, TResult, TError, TRequirements>(
37
+ schema: Schema.Struct<TFields>,
38
+ save: (values: TValues) => Effect.Effect<TResult, TError, TRequirements>,
39
+ reject: RejectField<TFields, TError> | undefined,
40
+ ) {
41
+ const rejection = reject ?? fieldRejectionOf(schema);
42
+ return (values: TValues, submitter: Submitter<Name<TFields>>): Effect.Effect<TResult, TError | FieldFailure, TRequirements> =>
43
+ Effect.catch(save(values), (error): Effect.Effect<never, TError | FieldFailure> => {
44
+ const found = rejection(error);
45
+ return found === undefined ? Effect.fail(error) : submitter.fail(found.field, found.message);
46
+ });
47
+ }
package/src/form.ts CHANGED
@@ -1,26 +1,17 @@
1
- import { Effect, Equal, Layer, Result, Schema, type SchemaIssue, SchemaParser } from "effect";
1
+ import { Effect, Layer, Result, Schema, type SchemaIssue, SchemaParser } from "effect";
2
2
  import * as AsyncResult from "effect/reactivity/AsyncResult";
3
3
  import * as Atom from "effect/reactivity/Atom";
4
4
  import type * as AtomRef from "effect/reactivity/AtomRef";
5
+ import { persistence } from "#adapters/persistence.ts";
6
+ import { checkFeedback } from "./checkFeedback.ts";
5
7
  import { draft } from "./draft.ts";
6
- import { literalChoices, messageAt, messagesByField, schemaFeedback } from "./messages.ts";
8
+ import { literalChoices, messageAt, schemaFeedback } from "./messages.ts";
7
9
  import { pathMessageAt } from "./paths.ts";
8
- import { persistence } from "./persistence.ts";
9
10
  import { fromRef, propertyRef } from "./refs.ts";
10
- import {
11
- type Config,
12
- type Decoded,
13
- type Encoded,
14
- FieldFailure,
15
- type Fields,
16
- type Form,
17
- Invalid,
18
- type Name,
19
- type Services,
20
- type Submitter,
21
- } from "./shape.ts";
11
+ import { type Config, type Decoded, type Encoded, FieldFailure, type Fields, type Form, type Name, type Services, type Submitter } from "./shape.ts";
22
12
  import { statusOf } from "./statusOf.ts";
23
13
  import { trackRejections } from "./trackRejections.ts";
14
+ import { validation } from "./validation.ts";
24
15
 
25
16
  const emptyRuntime = Atom.runtime(Layer.empty);
26
17
 
@@ -41,7 +32,7 @@ export function form<TFields extends Fields, TValue, TError, TServices = never,
41
32
  const checks: ReadonlyMap<string, (value: Encoded<TFields>[Name<TFields>]) => Effect.Effect<string | undefined, never, TServices>> = new Map(
42
33
  Object.entries(config.checks ?? {}),
43
34
  );
44
- const decode = SchemaParser.decodeUnknownEffect(schema, { errors: "all" });
35
+ const { decode, snapshot } = validation(schema, config.requiredMessages);
45
36
 
46
37
  const editing = draft(initialValues, config.persistedDraft ? persistence(schema, config.persistedDraft) : undefined);
47
38
  const { values } = editing;
@@ -71,26 +62,7 @@ export function form<TFields extends Fields, TValue, TError, TServices = never,
71
62
 
72
63
  const schemaErrors = schemaFeedback(decoded, config.requiredMessages);
73
64
 
74
- const checkError = Atom.family((name: Name<TFields>): Atom.Atom<string | undefined> => {
75
- const check = checks.get(name);
76
- if (check === undefined) {
77
- return Atom.make((): string | undefined => undefined);
78
- }
79
- const current = fromRef(refFor(name));
80
- const settled = Atom.debounce(current, debounce);
81
- const answered = runtime.atom((get) => {
82
- const input = get(settled);
83
- return Effect.map(check(input), (message) => ({
84
- input,
85
- message,
86
- }));
87
- });
88
- return Atom.readable((get): string | undefined => {
89
- const result = get(answered);
90
- const value = get(current);
91
- return AsyncResult.isSuccess(result) && !result.waiting && Equal.equals(result.value.input, value) ? result.value.message : undefined;
92
- });
93
- });
65
+ const checkError = checkFeedback<TFields, TServices, TRuntimeError>(runtime, checks, refFor, debounce);
94
66
 
95
67
  const error = Atom.family((name: Name<TFields>) =>
96
68
  Atom.readable((get): string | undefined => {
@@ -105,17 +77,20 @@ export function form<TFields extends Fields, TValue, TError, TServices = never,
105
77
  fail: (path, message) => Effect.fail(new FieldFailure(typeof path === "string" ? { message, path } : { message, path: path[0], segments: path })),
106
78
  };
107
79
 
80
+ const validate = runtime.fn<void>()(() => {
81
+ status.attempt();
82
+ return snapshot(values.value);
83
+ });
84
+
108
85
  const submit = runtime.fn<void>()(() =>
109
86
  Effect.gen(function* () {
110
87
  status.attempt();
111
88
  const submission = editing.submission();
112
89
  const submitted = submission.values;
113
- const value = yield* decode(submitted).pipe(
114
- Effect.mapError((issue) => new Invalid({ messages: messagesByField(issue, config.requiredMessages) })),
115
- );
90
+ const { decoded: value } = yield* snapshot(submitted);
116
91
  return yield* onSubmit(value, submitter, submitted).pipe(
117
92
  Effect.tapError(noted(submitted)),
118
- Effect.tap(() => Effect.sync(submission.accept)),
93
+ Effect.tap((saved) => Effect.sync(() => submission.accept(config.savedValues?.(saved)))),
119
94
  );
120
95
  }),
121
96
  );
@@ -133,10 +108,14 @@ export function form<TFields extends Fields, TValue, TError, TServices = never,
133
108
  },
134
109
  dirty: editing.dirty,
135
110
  error,
136
- errorAt: Atom.family((path: readonly [Name<TFields>, ...PropertyKey[]]) => {
111
+ errorAt: Atom.family((path: readonly [Name<TFields>, ...PropertyKey[]] | readonly []) => {
137
112
  const message = schemaErrors.at(path);
113
+ const name = path[0];
114
+ if (name === undefined) {
115
+ return Atom.readable((get) => (get(status.submitted) ? get(message) : undefined));
116
+ }
138
117
  return Atom.readable((get) =>
139
- get(status.touched)[path[0]] === true || get(status.submitted) ? (pathMessageAt(get(status.paths), path) ?? get(message)) : undefined,
118
+ get(status.touched)[name] === true || get(status.submitted) ? (pathMessageAt(get(status.paths), path) ?? get(message)) : undefined,
140
119
  );
141
120
  }),
142
121
  field: refFor,
@@ -144,6 +123,7 @@ export function form<TFields extends Fields, TValue, TError, TServices = never,
144
123
  revert: editing.revert,
145
124
  submit,
146
125
  submitting: Atom.map(submit, AsyncResult.isWaiting),
126
+ validate,
147
127
  values,
148
128
  };
149
129
  }
package/src/messages.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { Option, Result, SchemaAST, SchemaIssue, type StandardSchema } from "effect";
1
+ import { Result, SchemaAST, SchemaIssue, type StandardSchema } from "effect";
2
2
  import * as AsyncResult from "effect/reactivity/AsyncResult";
3
3
  import * as Atom from "effect/reactivity/Atom";
4
4
 
@@ -74,7 +74,8 @@ export function messageAtPath(
74
74
  ): string | undefined {
75
75
  for (const entry of formattedIssues(issue, requiredMessages)) {
76
76
  if (
77
- path.every((segment, index) => {
77
+ (path.length > 0 || !entry.path?.length)
78
+ && path.every((segment, index) => {
78
79
  const found = entry.path?.[index];
79
80
  return found !== undefined && segment === (typeof found === "object" ? found.key : found);
80
81
  })
@@ -121,23 +122,13 @@ export function schemaFeedback<A, E>(
121
122
  decoded: Atom.Atom<AsyncResult.AsyncResult<Result.Result<A, SchemaIssue.Issue>, E>>,
122
123
  requiredMessages: Readonly<Partial<Record<string, string>>> = noMessages,
123
124
  ) {
124
- const messages = Atom.map(decoded, (result) =>
125
- Option.match(AsyncResult.value(result), {
126
- onNone: () => noMessages,
127
- onSome: (value) => (Result.isFailure(value) ? messagesByField(value.failure, requiredMessages) : noMessages),
128
- }),
125
+ const issue = Atom.map(decoded, (result) =>
126
+ AsyncResult.isSuccess(result) && !result.waiting && Result.isFailure(result.value) ? result.value.failure : undefined,
129
127
  );
128
+ const messages = Atom.map(issue, (value) => (value === undefined ? noMessages : messagesByField(value, requiredMessages)));
130
129
  return {
131
130
  at: (path: readonly PropertyKey[]) =>
132
- Atom.map(decoded, (result): string | undefined => {
133
- if (result.waiting) {
134
- return undefined;
135
- }
136
- return Option.match(AsyncResult.value(result), {
137
- onNone: () => undefined,
138
- onSome: (value) => (Result.isFailure(value) ? messageAtPath(value.failure, path, requiredMessages) : undefined),
139
- });
140
- }),
131
+ Atom.map(issue, (value) => (value === undefined ? undefined : messageAtPath(value, path, requiredMessages))),
141
132
  field: Atom.family((name: string) => Atom.map(messages, (value) => messageAt(value, name))),
142
133
  };
143
134
  }
@@ -1,4 +1,4 @@
1
- import { Schema, SchemaParser } from "effect";
1
+ import { Context } from "effect";
2
2
  import type { DraftSession } from "./draftSession.ts";
3
3
 
4
4
  export interface DraftStorage {
@@ -7,6 +7,8 @@ export interface DraftStorage {
7
7
  readonly setItem: (key: string, value: string) => void;
8
8
  }
9
9
 
10
+ export class DraftStorageProvider extends Context.Service<DraftStorageProvider, () => DraftStorage>()("@shivaedev/form/DraftStorageProvider") {}
11
+
10
12
  export interface PersistedDraftConfig {
11
13
  readonly key: string;
12
14
  readonly maxAgeMs?: number;
@@ -21,88 +23,3 @@ export interface Persistence<TValues> {
21
23
  readonly read: () => { readonly baseline: TValues; readonly edited: readonly string[]; readonly values: TValues } | undefined;
22
24
  readonly write: (baseline: TValues, values: TValues, edited: readonly string[]) => void;
23
25
  }
24
-
25
- export function persistence<TSchema extends Schema.Constraint>(schema: TSchema, config: PersistedDraftConfig): Persistence<TSchema["Encoded"]> {
26
- const encoded = Schema.toEncoded(schema);
27
- const StoredDraft = Schema.Struct({
28
- baseline: Schema.Unknown,
29
- edited: Schema.Array(Schema.String),
30
- session: Schema.String,
31
- updatedAt: Schema.Number,
32
- values: Schema.Unknown,
33
- version: Schema.Literal(1),
34
- });
35
- const valid = Schema.is(StoredDraft);
36
- const decode = SchemaParser.decodeUnknownSync(encoded, { disableChecks: true });
37
- const maxAgeMs = config.maxAgeMs ?? 7 * 24 * 60 * 60 * 1000;
38
- const maxBytes = config.maxBytes ?? 256 * 1024;
39
- function bytes(value: string) {
40
- return new TextEncoder().encode(value).byteLength;
41
- }
42
- const storage = config.storage;
43
- const session = config.session?.id ?? "";
44
- function active() {
45
- return config.session?.active !== false;
46
- }
47
- function attempt<A>(run: () => A, failed?: () => void): A | undefined {
48
- try {
49
- return run();
50
- } catch {
51
- failed?.();
52
- return undefined;
53
- }
54
- }
55
- function clear() {
56
- attempt(() => storage().removeItem(config.key));
57
- }
58
- config.session?.track(config.key, clear);
59
- return {
60
- clear: () => {
61
- if (active()) {
62
- clear();
63
- }
64
- },
65
- read: () =>
66
- attempt(() => {
67
- if (!active()) {
68
- return undefined;
69
- }
70
- const raw = storage().getItem(config.key);
71
- if (raw === null) {
72
- return undefined;
73
- }
74
- const value: unknown = bytes(raw) <= maxBytes ? JSON.parse(raw) : undefined;
75
- const now = config.now();
76
- if (
77
- valid(value)
78
- && value.session === session
79
- && Number.isFinite(maxAgeMs)
80
- && Number.isFinite(maxBytes)
81
- && maxAgeMs > 0
82
- && maxBytes > 0
83
- && now - value.updatedAt <= maxAgeMs
84
- && value.updatedAt <= now
85
- ) {
86
- return { baseline: decode(value.baseline), edited: value.edited, values: decode(value.values) };
87
- }
88
- clear();
89
- return undefined;
90
- }, clear),
91
- write: (baseline, values, edited) => {
92
- if (!active()) {
93
- return;
94
- }
95
- attempt(() => {
96
- const raw = JSON.stringify({ baseline, edited, session, updatedAt: config.now(), values, version: 1 });
97
- const parsed: unknown = JSON.parse(raw);
98
- if (!(maxAgeMs > 0 && Number.isFinite(maxAgeMs) && Number.isFinite(maxBytes) && bytes(raw) <= maxBytes && valid(parsed))) {
99
- clear();
100
- return;
101
- }
102
- decode(parsed.baseline);
103
- decode(parsed.values);
104
- storage().setItem(config.key, raw);
105
- }, clear);
106
- },
107
- };
108
- }
package/src/refs.ts CHANGED
@@ -12,7 +12,6 @@ export function fromRef<TValue>(ref: AtomRef.ReadonlyRef<TValue>): Atom.Atom<TVa
12
12
  // Native prop refs retain removed keys; form fields must observe their absence.
13
13
  export function propertyRef<TValue, TKey extends keyof TValue>(parent: AtomRef.AtomRef<TValue>, name: TKey): AtomRef.AtomRef<TValue[TKey]> {
14
14
  const read = parent.map((value) => value[name]);
15
- const write = parent.prop(name);
16
15
  const ref: AtomRef.AtomRef<TValue[TKey]> = {
17
16
  [AtomRef.TypeId]: AtomRef.TypeId,
18
17
  [Equal.symbol]: (other) => Equal.equals(read, other),
@@ -21,7 +20,9 @@ export function propertyRef<TValue, TKey extends keyof TValue>(parent: AtomRef.A
21
20
  map: (transform) => read.map(transform),
22
21
  prop: (key) => propertyRef(ref, key),
23
22
  set: (value) => {
24
- write.set(value);
23
+ parent.update((current) =>
24
+ Array.isArray(current) ? Object.assign(current.slice(), current, { [name]: value }) : { ...current, [name]: value },
25
+ );
25
26
  return ref;
26
27
  },
27
28
  subscribe: (listener) => read.subscribe(listener),
package/src/shape.ts CHANGED
@@ -31,6 +31,7 @@ export class FieldFailure extends Data.TaggedError("FieldFailure")<{
31
31
  }> {}
32
32
 
33
33
  export class Invalid extends Data.TaggedError("Invalid")<{
34
+ readonly formMessage?: string | undefined;
34
35
  readonly messages: FieldMessages;
35
36
  }> {}
36
37
 
@@ -53,23 +54,30 @@ export type Config<TFields extends Fields, A, E, R, TRuntimeError> = {
53
54
  submitted: Encoded<TFields>,
54
55
  ) => Effect.Effect<A, E, R | Scope.Scope | AtomRegistry | Reactivity>;
55
56
  readonly persistedDraft?: PersistedDraftConfig | undefined;
57
+ readonly savedValues?: (value: A) => Encoded<TFields>;
56
58
  readonly requiredMessages?: { readonly [TKey in Name<TFields>]?: NonNullable<Encoded<TFields>[TKey]> extends string ? string : never };
57
59
  } & ([Exclude<Services<TFields, R>, Scope.Scope | AtomRegistry | Reactivity>] extends [never]
58
60
  ? { readonly runtime?: Atom.AtomRuntime<Services<TFields, R>, TRuntimeError> }
59
61
  : { readonly runtime: Atom.AtomRuntime<Services<TFields, R>, TRuntimeError> });
60
62
 
63
+ export interface Snapshot<TFields extends Fields> {
64
+ readonly decoded: Decoded<TFields>;
65
+ readonly encoded: Encoded<TFields>;
66
+ }
67
+
61
68
  export interface Form<TFields extends Fields, A, E, TRuntimeError> {
62
69
  readonly blur: (name: Name<TFields>) => void;
63
70
  readonly change: <TKey extends Name<TFields>>(name: TKey, value: Encoded<TFields>[TKey]) => void;
64
71
  readonly choices: <TKey extends Name<TFields>>(name: TKey) => readonly Encoded<TFields>[TKey][] | undefined;
65
72
  readonly dirty: Atom.Atom<boolean>;
66
73
  readonly error: (name: Name<TFields>) => Atom.Atom<string | undefined>;
67
- readonly errorAt: (path: Path<Name<TFields>>) => Atom.Atom<string | undefined>;
74
+ readonly errorAt: (path: Path<Name<TFields>> | readonly []) => Atom.Atom<string | undefined>;
68
75
  readonly field: <TKey extends Name<TFields>>(name: TKey) => AtomRef.AtomRef<Encoded<TFields>[TKey]>;
69
76
  readonly receive: (values: Encoded<TFields>) => void;
70
77
  readonly revert: () => void;
71
78
  readonly submit: Atom.AtomResultFn<void, A, E | TRuntimeError | FieldFailure | Invalid>;
72
79
  readonly submitting: Atom.Atom<boolean>;
80
+ readonly validate: Atom.AtomResultFn<void, Snapshot<TFields>, TRuntimeError | Invalid>;
73
81
  readonly values: AtomRef.AtomRef<Encoded<TFields>>;
74
82
  }
75
83
 
package/src/statusOf.ts CHANGED
@@ -56,11 +56,10 @@ export function statusOf() {
56
56
  submitted: fromRef(state.prop("submitted")),
57
57
  touch: (name: string): void => {
58
58
  state.update((current) =>
59
- current.touched[name] === true && !current.failures.some((entry) => entry.path.length === 1 && entry.path[0] === name)
59
+ current.touched[name] === true
60
60
  ? current
61
61
  : {
62
62
  ...current,
63
- failures: current.failures.filter((entry) => entry.path.length !== 1 || entry.path[0] !== name),
64
63
  touched: { ...current.touched, [name]: true },
65
64
  },
66
65
  );
@@ -0,0 +1,18 @@
1
+ import { Effect, type Schema, SchemaParser } from "effect";
2
+ import { messageAtPath, messagesByField } from "./messages.ts";
3
+ import { type Encoded, type Fields, Invalid, type Name, type Snapshot } from "./shape.ts";
4
+
5
+ export function validation<TFields extends Fields>(
6
+ schema: Schema.Struct<TFields>,
7
+ requiredMessages: Readonly<Partial<Record<Name<TFields>, string>>> | undefined,
8
+ ) {
9
+ const decode = SchemaParser.decodeUnknownEffect(schema, { errors: "all" });
10
+ return {
11
+ decode,
12
+ snapshot: (encoded: Encoded<TFields>) =>
13
+ decode(encoded).pipe(
14
+ Effect.map((decoded): Snapshot<TFields> => ({ decoded, encoded })),
15
+ Effect.mapError((issue) => new Invalid({ formMessage: messageAtPath(issue, []), messages: messagesByField(issue, requiredMessages) })),
16
+ ),
17
+ };
18
+ }
@@ -1 +0,0 @@
1
- {"version":3,"file":"react.d.ts","sourceRoot":"","sources":["../../src/client/react.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,WAAW,MAAM,+BAA+B,CAAC;AAElE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAC1E,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACpF,eAAO,MAAM,mBAAmB,mDAAqD,CAAC;AACtF,MAAM,WAAW,kBAAmB,SAAQ,IAAI,CAAC,oBAAoB,EAAE,SAAS,CAAC;IAChF,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,YAAY,CAAC;CACtC;AAID,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,kBAAkB,GAAG,oBAAoB,CAAC;AACpF,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,kBAAkB,GAAG,SAAS,GAAG,oBAAoB,GAAG,SAAS,CAAC;AAK5G,MAAM,WAAW,KAAK,CAAC,OAAO,SAAS,MAAM,EAAE,IAAI,SAAS,IAAI,CAAC,OAAO,CAAC;IACxE,QAAQ,CAAC,OAAO,EAAE,SAAS,OAAO,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,EAAE,GAAG,SAAS,CAAC;IAChE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IACnC,QAAQ,CAAC,IAAI,EAAE,IAAI,CAAC;IACpB,QAAQ,CAAC,MAAM,EAAE,MAAM,IAAI,CAAC;IAC5B,QAAQ,CAAC,QAAQ,EAAE,CAAC,KAAK,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,KAAK,IAAI,CAAC;IAC3D,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC;CACvC;AACD,wBAAgB,QAAQ,CAAC,OAAO,SAAS,MAAM,EAAE,CAAC,EAAE,CAAC,EAAE,aAAa,EAAE,IAAI,SAAS,IAAI,CAAC,OAAO,CAAC,EAC/F,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,EAAE,aAAa,CAAC,EACxC,IAAI,EAAE,IAAI,GACR,KAAK,CAAC,OAAO,EAAE,IAAI,CAAC,CAOtB;AACD,MAAM,WAAW,MAAM,CAAC,CAAC,EAAE,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,WAAW,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAC/C,QAAQ,CAAC,GAAG,EAAE,MAAM,IAAI,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;CAC7B;AACD,wBAAgB,SAAS,CAAC,OAAO,SAAS,MAAM,EAAE,CAAC,EAAE,CAAC,EAAE,aAAa,EACpE,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,EAAE,aAAa,CAAC,GACtC,MAAM,CAAC,CAAC,EAAE,CAAC,GAAG,aAAa,GAAG,YAAY,GAAG,OAAO,CAAC,CAKvD;AACD,wBAAgB,QAAQ,CAAC,OAAO,SAAS,MAAM,EAAE,CAAC,EAAE,CAAC,EAAE,aAAa,EAAE,IAAI,EAAE,IAAI,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC,EAAE,aAAa,CAAC,GAAG,OAAO,CAEvH"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"react.js","sourceRoot":"","sources":["../../src/client/react.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAE1E,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC;AAIxE,MAAM,CAAC,MAAM,mBAAmB,GAAG,aAAa,CAA2B,SAAS,CAAC,CAAC;AAItF,SAAS,cAAc;IACtB,OAAO,UAAU,CAAC,YAAY,CAAC;AAChC,CAAC;AAGD,MAAM,UAAU,iBAAiB,CAAC,MAAsC;IACvE,MAAM,OAAO,GAAG,UAAU,CAAC,mBAAmB,CAAC,CAAC;IAChD,OAAO,MAAM,CAAC,CAAC,CAAC,EAAE,GAAG,MAAM,EAAE,OAAO,EAAE,OAAO,IAAI,MAAM,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,CAAC,OAAO,IAAI,cAAc,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;AAC1H,CAAC;AASD,MAAM,UAAU,QAAQ,CACvB,IAAwC,EACxC,IAAU;IAEV,MAAM,KAAK,GAAG,UAAU,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;IAC3C,MAAM,KAAK,GAAG,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC;IAC7C,MAAM,QAAQ,GAAG,WAAW,CAAC,CAAC,IAA4B,EAAE,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;IACtG,MAAM,MAAM,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;IAChE,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;IAChE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC;AAC1D,CAAC;AAMD,MAAM,UAAU,SAAS,CACxB,IAAwC;IAExC,MAAM,MAAM,GAAG,YAAY,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACzC,MAAM,GAAG,GAAG,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IACpC,MAAM,GAAG,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC;IAC5C,OAAO,EAAE,MAAM,EAAE,GAAG,EAAE,UAAU,EAAE,MAAM,CAAC,OAAO,EAAE,CAAC;AACpD,CAAC;AACD,MAAM,UAAU,QAAQ,CAA8C,IAAwC;IAC7G,OAAO,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;AACjC,CAAC"}