@shivaedev/form 0.0.0 → 0.6.2
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 +109 -0
- package/LICENSE +21 -0
- package/README.md +314 -1
- package/dist/client/react.d.ts +27 -0
- package/dist/client/react.d.ts.map +1 -0
- package/dist/client/react.js +28 -0
- package/dist/client/react.js.map +1 -0
- package/dist/draft.d.ts +16 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +80 -0
- package/dist/draft.js.map +1 -0
- package/dist/draftSession.d.ts +8 -0
- package/dist/draftSession.d.ts.map +1 -0
- package/dist/draftSession.js +23 -0
- package/dist/draftSession.js.map +1 -0
- package/dist/emptyAsNull.d.ts +3 -0
- package/dist/emptyAsNull.d.ts.map +1 -0
- package/dist/emptyAsNull.js +8 -0
- package/dist/emptyAsNull.js.map +1 -0
- package/dist/form.d.ts +4 -0
- package/dist/form.d.ts.map +1 -0
- package/dist/form.js +101 -0
- package/dist/form.js.map +1 -0
- package/dist/messages.d.ts +15 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +95 -0
- package/dist/messages.js.map +1 -0
- package/dist/paths.d.ts +13 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +48 -0
- package/dist/paths.js.map +1 -0
- package/dist/persistence.d.ts +25 -0
- package/dist/persistence.d.ts.map +1 -0
- package/dist/persistence.js +84 -0
- package/dist/persistence.js.map +1 -0
- package/dist/refs.d.ts +5 -0
- package/dist/refs.d.ts.map +1 -0
- package/dist/refs.js +33 -0
- package/dist/refs.js.map +1 -0
- package/dist/shape.d.ts +68 -0
- package/dist/shape.d.ts.map +1 -0
- package/dist/shape.js +18 -0
- package/dist/shape.js.map +1 -0
- package/dist/statusOf.d.ts +14 -0
- package/dist/statusOf.d.ts.map +1 -0
- package/dist/statusOf.js +60 -0
- package/dist/statusOf.js.map +1 -0
- package/dist/trackRejections.d.ts +5 -0
- package/dist/trackRejections.d.ts.map +1 -0
- package/dist/trackRejections.js +20 -0
- package/dist/trackRejections.js.map +1 -0
- package/package.json +78 -4
- package/src/client/react.ts +54 -0
- package/src/draft.ts +98 -0
- package/src/draftSession.ts +29 -0
- package/src/emptyAsNull.ts +12 -0
- package/src/form.ts +142 -0
- package/src/messages.ts +114 -0
- package/src/paths.ts +63 -0
- package/src/persistence.ts +107 -0
- package/src/refs.ts +34 -0
- package/src/shape.ts +94 -0
- package/src/statusOf.ts +70 -0
- package/src/trackRejections.ts +23 -0
package/src/messages.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Option, Result, SchemaAST, SchemaIssue, type StandardSchema } from "effect";
|
|
2
|
+
import * as AsyncResult from "effect/reactivity/AsyncResult";
|
|
3
|
+
import * as Atom from "effect/reactivity/Atom";
|
|
4
|
+
|
|
5
|
+
export type FieldMessages = Readonly<Record<string, string>>;
|
|
6
|
+
|
|
7
|
+
export const noMessages: FieldMessages = {};
|
|
8
|
+
|
|
9
|
+
const REQUIRED = "Required";
|
|
10
|
+
|
|
11
|
+
const MINIMUM_LENGTH = "effect/schema/isMinLength";
|
|
12
|
+
|
|
13
|
+
function refusesEmpty(check: SchemaAST.Filter<unknown>): boolean {
|
|
14
|
+
const representation = check.annotations?.representation;
|
|
15
|
+
if (representation === undefined || representation.id !== MINIMUM_LENGTH) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
const payload = representation.payload;
|
|
19
|
+
if (typeof payload !== "object" || payload === null || !("minLength" in payload)) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
return payload.minLength === 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const checkHook: SchemaIssue.CheckHook = (issue) => SchemaIssue.defaultCheckHook(issue) ?? (refusesEmpty(issue.filter) ? REQUIRED : undefined);
|
|
26
|
+
|
|
27
|
+
const formatIssue = SchemaIssue.makeFormatterStandardSchemaV1({ checkHook });
|
|
28
|
+
|
|
29
|
+
function segmentKey(segment: PropertyKey | StandardSchema.StandardSchemaV1.PathSegment): string {
|
|
30
|
+
return typeof segment === "object" ? String(segment.key) : String(segment);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function messagesByField(issue: SchemaIssue.Issue): FieldMessages {
|
|
34
|
+
const messages: Record<string, string> = Object.create(null);
|
|
35
|
+
for (const entry of formatIssue(issue).issues) {
|
|
36
|
+
const head = entry.path?.[0];
|
|
37
|
+
if (head === undefined) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const name = segmentKey(head);
|
|
41
|
+
if (!Object.hasOwn(messages, name)) {
|
|
42
|
+
messages[name] = entry.message;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return messages;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function messageAtPath(issue: SchemaIssue.Issue, path: readonly PropertyKey[]): string | undefined {
|
|
49
|
+
for (const entry of formatIssue(issue).issues) {
|
|
50
|
+
if (
|
|
51
|
+
path.every((segment, index) => {
|
|
52
|
+
const found = entry.path?.[index];
|
|
53
|
+
return found !== undefined && segment === (typeof found === "object" ? found.key : found);
|
|
54
|
+
})
|
|
55
|
+
) {
|
|
56
|
+
return entry.message;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function withoutField(messages: FieldMessages, name: string): FieldMessages {
|
|
63
|
+
if (!Object.hasOwn(messages, name)) {
|
|
64
|
+
return messages;
|
|
65
|
+
}
|
|
66
|
+
const next: Record<string, string> = Object.create(null);
|
|
67
|
+
for (const [key, message] of Object.entries(messages)) {
|
|
68
|
+
if (key !== name) {
|
|
69
|
+
next[key] = message;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return next;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function literalChoices(input: SchemaAST.AST): readonly SchemaAST.LiteralValue[] | undefined {
|
|
76
|
+
const ast = SchemaAST.toEncoded(input);
|
|
77
|
+
if (ast._tag !== "Union") {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
const choices: SchemaAST.LiteralValue[] = [];
|
|
81
|
+
for (const member of ast.types) {
|
|
82
|
+
if (member._tag !== "Literal") {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
choices.push(member.literal);
|
|
86
|
+
}
|
|
87
|
+
return choices;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function messageAt(messages: FieldMessages, name: string): string | undefined {
|
|
91
|
+
return Object.hasOwn(messages, name) ? messages[name] : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function schemaFeedback<A, E>(decoded: Atom.Atom<AsyncResult.AsyncResult<Result.Result<A, SchemaIssue.Issue>, E>>) {
|
|
95
|
+
const messages = Atom.map(decoded, (result) =>
|
|
96
|
+
Option.match(AsyncResult.value(result), {
|
|
97
|
+
onNone: () => noMessages,
|
|
98
|
+
onSome: (value) => (Result.isFailure(value) ? messagesByField(value.failure) : noMessages),
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
return {
|
|
102
|
+
at: (path: readonly PropertyKey[]) =>
|
|
103
|
+
Atom.map(decoded, (result): string | undefined => {
|
|
104
|
+
if (result.waiting) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
return Option.match(AsyncResult.value(result), {
|
|
108
|
+
onNone: () => undefined,
|
|
109
|
+
onSome: (value) => (Result.isFailure(value) ? messageAtPath(value.failure, path) : undefined),
|
|
110
|
+
});
|
|
111
|
+
}),
|
|
112
|
+
field: Atom.family((name: string) => Atom.map(messages, (value) => messageAt(value, name))),
|
|
113
|
+
};
|
|
114
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Equal } from "effect";
|
|
2
|
+
import type { Path } from "./shape.ts";
|
|
3
|
+
|
|
4
|
+
type Key = (element: unknown) => PropertyKey;
|
|
5
|
+
export type KeySelectors = ReadonlyMap<string, Key>;
|
|
6
|
+
|
|
7
|
+
export interface PathMessage {
|
|
8
|
+
readonly message: string;
|
|
9
|
+
readonly path: Path;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function keySelector(selector: unknown): Key;
|
|
13
|
+
export function keySelector(selector: unknown): unknown {
|
|
14
|
+
return selector;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function asPath(path: string | Path): Path {
|
|
18
|
+
return typeof path === "string" ? [path] : path;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function lookup(value: unknown, path: readonly PropertyKey[]): { readonly value: unknown } | undefined {
|
|
22
|
+
let current = value;
|
|
23
|
+
for (const [index, segment] of path.entries()) {
|
|
24
|
+
if (typeof current !== "object" || current === null) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
if (!Object.hasOwn(current, segment)) {
|
|
28
|
+
return index === path.length - 1 && !Array.isArray(current) ? { value: undefined } : undefined;
|
|
29
|
+
}
|
|
30
|
+
current = Reflect.get(current, segment);
|
|
31
|
+
}
|
|
32
|
+
return { value: current };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function uniqueIndex(values: readonly unknown[], key: PropertyKey, select: Key): number | undefined {
|
|
36
|
+
const matches = values.flatMap((value, index) => (select(value) === key ? [index] : []));
|
|
37
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function retainedPath(path: Path, previous: unknown, current: unknown, keys: KeySelectors): Path | undefined {
|
|
41
|
+
const select = keys.get(path[0]);
|
|
42
|
+
let next = path;
|
|
43
|
+
if (select !== undefined && typeof path[1] === "number") {
|
|
44
|
+
const before = lookup(previous, [path[0]])?.value;
|
|
45
|
+
const after = lookup(current, [path[0]])?.value;
|
|
46
|
+
if (!(Array.isArray(before) && Array.isArray(after) && Object.hasOwn(before, path[1]))) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
const key = select(before[path[1]]);
|
|
50
|
+
const index = uniqueIndex(after, key, select);
|
|
51
|
+
if (index === undefined || uniqueIndex(before, key, select) !== path[1]) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
next = [path[0], index, ...path.slice(2)];
|
|
55
|
+
}
|
|
56
|
+
const before = lookup(previous, path);
|
|
57
|
+
const after = lookup(current, next);
|
|
58
|
+
return before !== undefined && after !== undefined && Equal.equals(before.value, after.value) ? next : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function pathMessageAt(messages: readonly PathMessage[], path: readonly PropertyKey[]): string | undefined {
|
|
62
|
+
return messages.find((entry) => path.every((segment, index) => segment === entry.path[index]))?.message;
|
|
63
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { Clock, Effect, Schema, SchemaParser } from "effect";
|
|
2
|
+
import type { DraftSession } from "./draftSession.ts";
|
|
3
|
+
|
|
4
|
+
export interface DraftStorage {
|
|
5
|
+
readonly getItem: (key: string) => string | null;
|
|
6
|
+
readonly removeItem: (key: string) => void;
|
|
7
|
+
readonly setItem: (key: string, value: string) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface PersistedDraftConfig {
|
|
11
|
+
readonly key: string;
|
|
12
|
+
readonly maxAgeMs?: number;
|
|
13
|
+
readonly maxBytes?: number;
|
|
14
|
+
readonly session?: DraftSession | undefined;
|
|
15
|
+
readonly storage: () => DraftStorage;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Persistence<TValues> {
|
|
19
|
+
readonly clear: () => void;
|
|
20
|
+
readonly read: () => { readonly baseline: TValues; readonly edited: readonly string[]; readonly values: TValues } | undefined;
|
|
21
|
+
readonly write: (baseline: TValues, values: TValues, edited: readonly string[]) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function persistence<TSchema extends Schema.Constraint>(schema: TSchema, config: PersistedDraftConfig): Persistence<TSchema["Encoded"]> {
|
|
25
|
+
const encoded = Schema.toEncoded(schema);
|
|
26
|
+
const StoredDraft = Schema.Struct({
|
|
27
|
+
baseline: Schema.Unknown,
|
|
28
|
+
edited: Schema.Array(Schema.String),
|
|
29
|
+
session: Schema.String,
|
|
30
|
+
updatedAt: Schema.Number,
|
|
31
|
+
values: Schema.Unknown,
|
|
32
|
+
version: Schema.Literal(1),
|
|
33
|
+
});
|
|
34
|
+
const valid = Schema.is(StoredDraft);
|
|
35
|
+
const decode = SchemaParser.decodeUnknownSync(encoded, { disableChecks: true });
|
|
36
|
+
const maxAgeMs = config.maxAgeMs ?? 7 * 24 * 60 * 60 * 1000;
|
|
37
|
+
const maxBytes = config.maxBytes ?? 256 * 1024;
|
|
38
|
+
function bytes(value: string) {
|
|
39
|
+
return new TextEncoder().encode(value).byteLength;
|
|
40
|
+
}
|
|
41
|
+
const storage = config.storage;
|
|
42
|
+
const session = config.session?.id ?? "";
|
|
43
|
+
function active() {
|
|
44
|
+
return config.session?.active !== false;
|
|
45
|
+
}
|
|
46
|
+
function attempt<A>(run: () => A, failed?: () => void): A | undefined {
|
|
47
|
+
try {
|
|
48
|
+
return run();
|
|
49
|
+
} catch {
|
|
50
|
+
failed?.();
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function clear() {
|
|
55
|
+
attempt(() => storage().removeItem(config.key));
|
|
56
|
+
}
|
|
57
|
+
config.session?.track(config.key, clear);
|
|
58
|
+
return {
|
|
59
|
+
clear: () => {
|
|
60
|
+
if (active()) {
|
|
61
|
+
clear();
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
read: () =>
|
|
65
|
+
attempt(() => {
|
|
66
|
+
if (!active()) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const raw = storage().getItem(config.key);
|
|
70
|
+
if (raw === null) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
const value: unknown = bytes(raw) <= maxBytes ? JSON.parse(raw) : undefined;
|
|
74
|
+
const now = Effect.runSync(Clock.currentTimeMillis);
|
|
75
|
+
if (
|
|
76
|
+
valid(value)
|
|
77
|
+
&& value.session === session
|
|
78
|
+
&& Number.isFinite(maxAgeMs)
|
|
79
|
+
&& Number.isFinite(maxBytes)
|
|
80
|
+
&& maxAgeMs > 0
|
|
81
|
+
&& maxBytes > 0
|
|
82
|
+
&& now - value.updatedAt <= maxAgeMs
|
|
83
|
+
&& value.updatedAt <= now
|
|
84
|
+
) {
|
|
85
|
+
return { baseline: decode(value.baseline), edited: value.edited, values: decode(value.values) };
|
|
86
|
+
}
|
|
87
|
+
clear();
|
|
88
|
+
return undefined;
|
|
89
|
+
}, clear),
|
|
90
|
+
write: (baseline, values, edited) => {
|
|
91
|
+
if (!active()) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
attempt(() => {
|
|
95
|
+
const raw = JSON.stringify({ baseline, edited, session, updatedAt: Effect.runSync(Clock.currentTimeMillis), values, version: 1 });
|
|
96
|
+
const parsed: unknown = JSON.parse(raw);
|
|
97
|
+
if (!(maxAgeMs > 0 && Number.isFinite(maxAgeMs) && Number.isFinite(maxBytes) && bytes(raw) <= maxBytes && valid(parsed))) {
|
|
98
|
+
clear();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
decode(parsed.baseline);
|
|
102
|
+
decode(parsed.values);
|
|
103
|
+
storage().setItem(config.key, raw);
|
|
104
|
+
}, clear);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
package/src/refs.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Equal, Hash } from "effect";
|
|
2
|
+
import * as Atom from "effect/reactivity/Atom";
|
|
3
|
+
import * as AtomRef from "effect/reactivity/AtomRef";
|
|
4
|
+
|
|
5
|
+
export function fromRef<TValue>(ref: AtomRef.ReadonlyRef<TValue>): Atom.Atom<TValue> {
|
|
6
|
+
return Atom.readable((get) => {
|
|
7
|
+
get.addFinalizer(ref.subscribe((value) => get.setSelf(value)));
|
|
8
|
+
return ref.value;
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Native prop refs retain removed keys; form fields must observe their absence.
|
|
13
|
+
export function propertyRef<TValue, TKey extends keyof TValue>(parent: AtomRef.AtomRef<TValue>, name: TKey): AtomRef.AtomRef<TValue[TKey]> {
|
|
14
|
+
const read = parent.map((value) => value[name]);
|
|
15
|
+
const write = parent.prop(name);
|
|
16
|
+
const ref: AtomRef.AtomRef<TValue[TKey]> = {
|
|
17
|
+
[AtomRef.TypeId]: AtomRef.TypeId,
|
|
18
|
+
[Equal.symbol]: (other) => Equal.equals(read, other),
|
|
19
|
+
[Hash.symbol]: () => Hash.hash(read),
|
|
20
|
+
key: read.key,
|
|
21
|
+
map: (transform) => read.map(transform),
|
|
22
|
+
prop: (key) => propertyRef(ref, key),
|
|
23
|
+
set: (value) => {
|
|
24
|
+
write.set(value);
|
|
25
|
+
return ref;
|
|
26
|
+
},
|
|
27
|
+
subscribe: (listener) => read.subscribe(listener),
|
|
28
|
+
update: (transform) => ref.set(transform(read.value)),
|
|
29
|
+
get value() {
|
|
30
|
+
return read.value;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
return ref;
|
|
34
|
+
}
|
package/src/shape.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Data, type Duration, type Effect, type Schema, type Scope } from "effect";
|
|
2
|
+
import type * as Atom from "effect/reactivity/Atom";
|
|
3
|
+
import type * as AtomRef from "effect/reactivity/AtomRef";
|
|
4
|
+
import type { AtomRegistry } from "effect/reactivity/AtomRegistry";
|
|
5
|
+
import type { Reactivity } from "effect/reactivity/Reactivity";
|
|
6
|
+
import type { FieldMessages } from "./messages.ts";
|
|
7
|
+
import type { PersistedDraftConfig } from "./persistence.ts";
|
|
8
|
+
|
|
9
|
+
export type Fields = Schema.Struct.Fields;
|
|
10
|
+
|
|
11
|
+
export type Encoded<TFields extends Fields> = Schema.Struct.Encoded<TFields>;
|
|
12
|
+
|
|
13
|
+
export type Decoded<TFields extends Fields> = Schema.Struct.Type<TFields>;
|
|
14
|
+
|
|
15
|
+
export type Name<TFields extends Fields> = keyof Encoded<TFields> & string;
|
|
16
|
+
|
|
17
|
+
export type Path<TName extends string = string> = readonly [TName, ...PropertyKey[]];
|
|
18
|
+
|
|
19
|
+
export type ArrayKeys<TFields extends Fields> = {
|
|
20
|
+
readonly [TKey in Name<TFields>]?: NonNullable<Encoded<TFields>[TKey]> extends readonly (infer TElement)[]
|
|
21
|
+
? (element: TElement) => PropertyKey
|
|
22
|
+
: never;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type Services<TFields extends Fields, R> = R | Schema.Struct.DecodingServices<TFields>;
|
|
26
|
+
|
|
27
|
+
export class FieldFailure extends Data.TaggedError("FieldFailure")<{
|
|
28
|
+
readonly message: string;
|
|
29
|
+
readonly path: string;
|
|
30
|
+
readonly segments?: Path;
|
|
31
|
+
}> {}
|
|
32
|
+
|
|
33
|
+
export class Invalid extends Data.TaggedError("Invalid")<{
|
|
34
|
+
readonly messages: FieldMessages;
|
|
35
|
+
}> {}
|
|
36
|
+
|
|
37
|
+
export interface Submitter<TName extends string> {
|
|
38
|
+
readonly fail: (path: TName | Path<TName>, message: string) => Effect.Effect<never, FieldFailure>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type Checks<TFields extends Fields, R> = {
|
|
42
|
+
readonly [TKey in Name<TFields>]?: (value: Encoded<TFields>[TKey]) => Effect.Effect<string | undefined, never, R>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export interface Config<TFields extends Fields, A, E, R, TRuntimeError> {
|
|
46
|
+
readonly arrayKeys?: ArrayKeys<TFields>;
|
|
47
|
+
readonly checks?: Checks<TFields, R>;
|
|
48
|
+
readonly debounce?: Duration.Input;
|
|
49
|
+
readonly initialValues: Encoded<TFields>;
|
|
50
|
+
readonly onSubmit: (
|
|
51
|
+
values: Decoded<TFields>,
|
|
52
|
+
submitter: Submitter<Name<TFields>>,
|
|
53
|
+
submitted: Encoded<TFields>,
|
|
54
|
+
) => Effect.Effect<A, E, R | Scope.Scope | AtomRegistry | Reactivity>;
|
|
55
|
+
readonly persistedDraft?: PersistedDraftConfig | undefined;
|
|
56
|
+
readonly runtime: Atom.AtomRuntime<Services<TFields, R>, TRuntimeError>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface Form<TFields extends Fields, A, E, TRuntimeError> {
|
|
60
|
+
readonly blur: (name: Name<TFields>) => void;
|
|
61
|
+
readonly change: <TKey extends Name<TFields>>(name: TKey, value: Encoded<TFields>[TKey]) => void;
|
|
62
|
+
readonly choices: <TKey extends Name<TFields>>(name: TKey) => readonly Encoded<TFields>[TKey][] | undefined;
|
|
63
|
+
readonly dirty: Atom.Atom<boolean>;
|
|
64
|
+
readonly error: (name: Name<TFields>) => Atom.Atom<string | undefined>;
|
|
65
|
+
readonly errorAt: (path: Path<Name<TFields>>) => Atom.Atom<string | undefined>;
|
|
66
|
+
readonly field: <TKey extends Name<TFields>>(name: TKey) => AtomRef.AtomRef<Encoded<TFields>[TKey]>;
|
|
67
|
+
readonly receive: (values: Encoded<TFields>) => void;
|
|
68
|
+
readonly revert: () => void;
|
|
69
|
+
readonly submit: Atom.AtomResultFn<void, A, E | TRuntimeError | FieldFailure | Invalid>;
|
|
70
|
+
readonly submitting: Atom.Atom<boolean>;
|
|
71
|
+
readonly values: AtomRef.AtomRef<Encoded<TFields>>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type Holder = AtomRef.AtomRef<Readonly<Record<string, unknown>>>;
|
|
75
|
+
|
|
76
|
+
export function holder(values: object): Holder;
|
|
77
|
+
export function holder(values: object): unknown {
|
|
78
|
+
return values;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function fieldOf<TValue>(ref: AtomRef.AtomRef<unknown>): AtomRef.AtomRef<TValue>;
|
|
82
|
+
export function fieldOf(ref: AtomRef.AtomRef<unknown>): unknown {
|
|
83
|
+
return ref;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function choicesOf<TValue>(choices: readonly unknown[] | undefined): readonly TValue[] | undefined;
|
|
87
|
+
export function choicesOf(choices: readonly unknown[] | undefined): unknown {
|
|
88
|
+
return choices;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function checkOf<R>(check: unknown): (value: unknown) => Effect.Effect<string | undefined, never, R>;
|
|
92
|
+
export function checkOf(check: unknown): unknown {
|
|
93
|
+
return check;
|
|
94
|
+
}
|
package/src/statusOf.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Equal } from "effect";
|
|
2
|
+
import * as Atom from "effect/reactivity/Atom";
|
|
3
|
+
import * as AtomRef from "effect/reactivity/AtomRef";
|
|
4
|
+
import type { FieldMessages } from "./messages.ts";
|
|
5
|
+
import { asPath, type KeySelectors, type PathMessage, retainedPath } from "./paths.ts";
|
|
6
|
+
import { fromRef } from "./refs.ts";
|
|
7
|
+
import type { Path } from "./shape.ts";
|
|
8
|
+
|
|
9
|
+
interface State {
|
|
10
|
+
readonly failures: readonly PathMessage[];
|
|
11
|
+
readonly submitted: boolean;
|
|
12
|
+
readonly touched: Readonly<Record<string, true>>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function statusOf() {
|
|
16
|
+
const state = AtomRef.make<State>({
|
|
17
|
+
failures: [],
|
|
18
|
+
submitted: false,
|
|
19
|
+
touched: {},
|
|
20
|
+
});
|
|
21
|
+
const paths = fromRef(state.prop("failures"));
|
|
22
|
+
return {
|
|
23
|
+
attempt: (): void => {
|
|
24
|
+
state.update((current) => ({
|
|
25
|
+
...current,
|
|
26
|
+
failures: [],
|
|
27
|
+
submitted: true,
|
|
28
|
+
}));
|
|
29
|
+
},
|
|
30
|
+
failures: Atom.map(paths, (entries): FieldMessages => {
|
|
31
|
+
const messages: Record<string, string> = Object.create(null);
|
|
32
|
+
for (const { path, message } of entries) {
|
|
33
|
+
if (!Object.hasOwn(messages, path[0])) {
|
|
34
|
+
messages[path[0]] = message;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return messages;
|
|
38
|
+
}),
|
|
39
|
+
note: (input: string | Path, message: string): void => {
|
|
40
|
+
const path = asPath(input);
|
|
41
|
+
state.update((current) => ({
|
|
42
|
+
...current,
|
|
43
|
+
failures: [...current.failures.filter((entry) => !Equal.equals(entry.path, path)), { message, path }],
|
|
44
|
+
}));
|
|
45
|
+
},
|
|
46
|
+
paths,
|
|
47
|
+
rebase: (previous: unknown, next: unknown, keys: KeySelectors): void => {
|
|
48
|
+
state.update((current) => ({
|
|
49
|
+
...current,
|
|
50
|
+
failures: current.failures.flatMap((entry) => {
|
|
51
|
+
const path = retainedPath(entry.path, previous, next, keys);
|
|
52
|
+
return path === undefined ? [] : [{ ...entry, path }];
|
|
53
|
+
}),
|
|
54
|
+
}));
|
|
55
|
+
},
|
|
56
|
+
submitted: fromRef(state.prop("submitted")),
|
|
57
|
+
touch: (name: string): void => {
|
|
58
|
+
state.update((current) =>
|
|
59
|
+
current.touched[name] === true && !current.failures.some((entry) => entry.path.length === 1 && entry.path[0] === name)
|
|
60
|
+
? current
|
|
61
|
+
: {
|
|
62
|
+
...current,
|
|
63
|
+
failures: current.failures.filter((entry) => entry.path.length !== 1 || entry.path[0] !== name),
|
|
64
|
+
touched: { ...current.touched, [name]: true },
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
},
|
|
68
|
+
touched: fromRef(state.prop("touched")),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { asPath, keySelector, retainedPath } from "./paths.ts";
|
|
3
|
+
import { FieldFailure, type Holder } from "./shape.ts";
|
|
4
|
+
import type { statusOf } from "./statusOf.ts";
|
|
5
|
+
|
|
6
|
+
export function trackRejections(values: Holder, selectors: Readonly<Record<string, unknown>> | undefined, status: ReturnType<typeof statusOf>) {
|
|
7
|
+
const keys = new Map(Object.entries(selectors ?? {}).map(([name, select]) => [name, keySelector(select)]));
|
|
8
|
+
let previous = values.value;
|
|
9
|
+
values.subscribe((next) => {
|
|
10
|
+
status.rebase(previous, next, keys);
|
|
11
|
+
previous = next;
|
|
12
|
+
});
|
|
13
|
+
return (submitted: unknown) =>
|
|
14
|
+
(cause: unknown): Effect.Effect<void> =>
|
|
15
|
+
Effect.sync(() => {
|
|
16
|
+
if (cause instanceof FieldFailure) {
|
|
17
|
+
const path = retainedPath(cause.segments ?? asPath(cause.path), submitted, values.value, keys);
|
|
18
|
+
if (path !== undefined) {
|
|
19
|
+
status.note(path, cause.message);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}
|