@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.
- package/CHANGELOG.md +29 -0
- package/README.md +62 -22
- package/dist/adapters/browserDraftStorage.d.ts +4 -0
- package/dist/adapters/browserDraftStorage.d.ts.map +1 -0
- package/dist/adapters/browserDraftStorage.js +4 -0
- package/dist/adapters/browserDraftStorage.js.map +1 -0
- package/dist/adapters/draftPersistence.d.ts +7 -0
- package/dist/adapters/draftPersistence.d.ts.map +1 -0
- package/dist/adapters/draftPersistence.js +8 -0
- package/dist/adapters/draftPersistence.js.map +1 -0
- package/dist/adapters/persistence.d.ts +4 -0
- package/dist/adapters/persistence.d.ts.map +1 -0
- package/dist/adapters/persistence.js +84 -0
- package/dist/adapters/persistence.js.map +1 -0
- package/dist/{client → adapters}/react.d.ts +3 -6
- package/dist/adapters/react.d.ts.map +1 -0
- package/dist/{client → adapters}/react.js +1 -4
- package/dist/adapters/react.js.map +1 -0
- package/dist/checkFeedback.d.ts +6 -0
- package/dist/checkFeedback.d.ts.map +1 -0
- package/dist/checkFeedback.js +27 -0
- package/dist/checkFeedback.js.map +1 -0
- package/dist/draft.d.ts +1 -1
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +12 -2
- package/dist/draft.js.map +1 -1
- package/dist/fieldRejection.d.ts +22 -0
- package/dist/fieldRejection.d.ts.map +1 -0
- package/dist/fieldRejection.js +21 -0
- package/dist/fieldRejection.js.map +1 -0
- package/dist/form.d.ts.map +1 -1
- package/dist/form.js +20 -28
- package/dist/form.js.map +1 -1
- package/dist/messages.d.ts.map +1 -1
- package/dist/messages.js +9 -18
- package/dist/messages.js.map +1 -1
- package/dist/persistence.d.ts +5 -2
- package/dist/persistence.d.ts.map +1 -1
- package/dist/persistence.js +2 -82
- package/dist/persistence.js.map +1 -1
- package/dist/refs.d.ts.map +1 -1
- package/dist/refs.js +1 -2
- package/dist/refs.js.map +1 -1
- package/dist/shape.d.ts +8 -1
- package/dist/shape.d.ts.map +1 -1
- package/dist/shape.js.map +1 -1
- package/dist/statusOf.d.ts.map +1 -1
- package/dist/statusOf.js +1 -2
- package/dist/statusOf.js.map +1 -1
- package/dist/validation.d.ts +7 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +11 -0
- package/dist/validation.js.map +1 -0
- package/package.json +7 -4
- package/src/adapters/browserDraftStorage.ts +4 -0
- package/src/adapters/draftPersistence.ts +8 -0
- package/src/adapters/persistence.ts +87 -0
- package/src/{client → adapters}/react.ts +5 -11
- package/src/checkFeedback.ts +34 -0
- package/src/draft.ts +12 -4
- package/src/fieldRejection.ts +47 -0
- package/src/form.ts +22 -42
- package/src/messages.ts +7 -16
- package/src/persistence.ts +3 -86
- package/src/refs.ts +3 -2
- package/src/shape.ts +9 -1
- package/src/statusOf.ts +1 -2
- package/src/validation.ts +18 -0
- package/dist/client/react.d.ts.map +0 -1
- 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 {
|
|
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
|
|
9
|
-
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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 =
|
|
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 =
|
|
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*
|
|
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)[
|
|
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 {
|
|
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.
|
|
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
|
|
125
|
-
|
|
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(
|
|
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
|
}
|
package/src/persistence.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
|
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"}
|
package/dist/client/react.js.map
DELETED
|
@@ -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"}
|