@shivaedev/effect-form 0.0.0 → 0.1.1
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 +25 -0
- package/LICENSE +21 -0
- package/README.md +50 -1
- package/dist/draft.d.ts +15 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +57 -0
- package/dist/draft.js.map +1 -0
- package/dist/form.d.ts +4 -0
- package/dist/form.d.ts.map +1 -0
- package/dist/form.js +102 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/messages.d.ts +8 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +60 -0
- package/dist/messages.js.map +1 -0
- package/dist/optional.d.ts +3 -0
- package/dist/optional.d.ts.map +1 -0
- package/dist/optional.js +6 -0
- package/dist/optional.js.map +1 -0
- package/dist/react.d.ts +19 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +18 -0
- package/dist/react.js.map +1 -0
- package/dist/refs.d.ts +5 -0
- package/dist/refs.d.ts.map +1 -0
- package/dist/refs.js +31 -0
- package/dist/refs.js.map +1 -0
- package/dist/shape.d.ts +59 -0
- package/dist/shape.d.ts.map +1 -0
- package/dist/shape.js +18 -0
- package/dist/shape.js.map +1 -0
- package/dist/status.d.ts +9 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +38 -0
- package/dist/status.js.map +1 -0
- package/package.json +68 -4
- package/src/draft.ts +77 -0
- package/src/form.ts +145 -0
- package/src/index.ts +15 -0
- package/src/messages.ts +73 -0
- package/src/optional.ts +11 -0
- package/src/react.ts +37 -0
- package/src/refs.ts +33 -0
- package/src/shape.ts +81 -0
- package/src/status.ts +46 -0
package/dist/shape.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Data } from "effect";
|
|
2
|
+
export class FieldFailure extends Data.TaggedError("FieldFailure") {
|
|
3
|
+
}
|
|
4
|
+
export class Invalid extends Data.TaggedError("Invalid") {
|
|
5
|
+
}
|
|
6
|
+
export function holder(values) {
|
|
7
|
+
return values;
|
|
8
|
+
}
|
|
9
|
+
export function fieldOf(ref) {
|
|
10
|
+
return ref;
|
|
11
|
+
}
|
|
12
|
+
export function choicesOf(choices) {
|
|
13
|
+
return choices;
|
|
14
|
+
}
|
|
15
|
+
export function checkOf(check) {
|
|
16
|
+
return check;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=shape.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"shape.js","sourceRoot":"","sources":["../src/shape.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAuD,MAAM,QAAQ,CAAC;AAiBnF,MAAM,OAAO,YAAa,SAAQ,IAAI,CAAC,WAAW,CAAC,cAAc,CAG/D;CAAG;AAEL,MAAM,OAAO,OAAQ,SAAQ,IAAI,CAAC,WAAW,CAAC,SAAS,CAErD;CAAG;AAuCL,MAAM,UAAU,MAAM,CAAC,MAAc;IACpC,OAAO,MAAM,CAAC;AACf,CAAC;AAGD,MAAM,UAAU,OAAO,CAAC,GAA6B;IACpD,OAAO,GAAG,CAAC;AACZ,CAAC;AAGD,MAAM,UAAU,SAAS,CAAC,OAAuC;IAChE,OAAO,OAAO,CAAC;AAChB,CAAC;AAGD,MAAM,UAAU,OAAO,CAAC,KAAc;IACrC,OAAO,KAAK,CAAC;AACd,CAAC"}
|
package/dist/status.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const statusOf: () => {
|
|
2
|
+
attempt: () => void;
|
|
3
|
+
failures: import("effect/unstable/reactivity/Atom").Atom<Readonly<Record<string, string>>>;
|
|
4
|
+
note: (path: string, message: string) => void;
|
|
5
|
+
submitted: import("effect/unstable/reactivity/Atom").Atom<boolean>;
|
|
6
|
+
touch: (name: string) => void;
|
|
7
|
+
touched: import("effect/unstable/reactivity/Atom").Atom<Readonly<Record<string, true>>>;
|
|
8
|
+
};
|
|
9
|
+
//# sourceMappingURL=status.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"AAUA,eAAO,MAAM,QAAQ;mBAON,IAAI;;iBAQJ,MAAM,WAAW,MAAM,KAAG,IAAI;;kBAO7B,MAAM,KAAG,IAAI;;CAa5B,CAAC"}
|
package/dist/status.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as AtomRef from "effect/unstable/reactivity/AtomRef";
|
|
2
|
+
import { noMessages, withoutField } from "./messages.js";
|
|
3
|
+
import { fromRef } from "./refs.js";
|
|
4
|
+
export const statusOf = () => {
|
|
5
|
+
const state = AtomRef.make({
|
|
6
|
+
failures: noMessages,
|
|
7
|
+
submitted: false,
|
|
8
|
+
touched: {},
|
|
9
|
+
});
|
|
10
|
+
return {
|
|
11
|
+
attempt: () => {
|
|
12
|
+
state.update((current) => ({
|
|
13
|
+
...current,
|
|
14
|
+
failures: noMessages,
|
|
15
|
+
submitted: true,
|
|
16
|
+
}));
|
|
17
|
+
},
|
|
18
|
+
failures: fromRef(state.prop("failures")),
|
|
19
|
+
note: (path, message) => {
|
|
20
|
+
state.update((current) => ({
|
|
21
|
+
...current,
|
|
22
|
+
failures: { ...current.failures, [path]: message },
|
|
23
|
+
}));
|
|
24
|
+
},
|
|
25
|
+
submitted: fromRef(state.prop("submitted")),
|
|
26
|
+
touch: (name) => {
|
|
27
|
+
state.update((current) => current.touched[name] === true && !Object.hasOwn(current.failures, name)
|
|
28
|
+
? current
|
|
29
|
+
: {
|
|
30
|
+
...current,
|
|
31
|
+
failures: withoutField(current.failures, name),
|
|
32
|
+
touched: { ...current.touched, [name]: true },
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
touched: fromRef(state.prop("touched")),
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
//# sourceMappingURL=status.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"status.js","sourceRoot":"","sources":["../src/status.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,OAAO,MAAM,oCAAoC,CAAC;AAC9D,OAAO,EAAsB,UAAU,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAC7E,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAQpC,MAAM,CAAC,MAAM,QAAQ,GAAG,GAAG,EAAE;IAC5B,MAAM,KAAK,GAAG,OAAO,CAAC,IAAI,CAAQ;QACjC,QAAQ,EAAE,UAAU;QACpB,SAAS,EAAE,KAAK;QAChB,OAAO,EAAE,EAAE;KACX,CAAC,CAAC;IACH,OAAO;QACN,OAAO,EAAE,GAAS,EAAE;YACnB,KAAK,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;gBAC1B,GAAG,OAAO;gBACV,QAAQ,EAAE,UAAU;gBACpB,SAAS,EAAE,IAAI;aACf,CAAC,CAAC,CAAC;QACL,CAAC;QACD,QAAQ,EAAE,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QACzC,IAAI,EAAE,CAAC,IAAY,EAAE,OAAe,EAAQ,EAAE;YAC7C,KAAK,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;gBAC1B,GAAG,OAAO;gBACV,QAAQ,EAAE,EAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE;aAClD,CAAC,CAAC,CAAC;QACL,CAAC;QACD,SAAS,EAAE,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;QAC3C,KAAK,EAAE,CAAC,IAAY,EAAQ,EAAE;YAC7B,KAAK,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CACxB,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,IAAI,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,QAAQ,EAAE,IAAI,CAAC;gBACvE,CAAC,CAAC,OAAO;gBACT,CAAC,CAAC;oBACA,GAAG,OAAO;oBACV,QAAQ,EAAE,YAAY,CAAC,OAAO,CAAC,QAAQ,EAAE,IAAI,CAAC;oBAC9C,OAAO,EAAE,EAAE,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE;iBAC7C,CACH,CAAC;QACH,CAAC;QACD,OAAO,EAAE,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;KACvC,CAAC;AACH,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,14 +1,78 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shivaedev/effect-form",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Schema-derived Effect atom forms with optional React hooks",
|
|
5
|
+
"type": "module",
|
|
5
6
|
"license": "MIT",
|
|
6
7
|
"repository": {
|
|
7
8
|
"type": "git",
|
|
8
9
|
"url": "git+https://github.com/ShivaeDev/platform.git",
|
|
9
10
|
"directory": "packages/effect-form"
|
|
10
11
|
},
|
|
12
|
+
"homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/effect-form#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/ShivaeDev/platform/issues"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=24"
|
|
18
|
+
},
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"src",
|
|
23
|
+
"CHANGELOG.md",
|
|
24
|
+
"README.md"
|
|
25
|
+
],
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"source": "./src/index.ts",
|
|
30
|
+
"import": "./dist/index.js",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./package.json": "./package.json",
|
|
34
|
+
"./react": {
|
|
35
|
+
"types": "./dist/react.d.ts",
|
|
36
|
+
"source": "./src/react.ts",
|
|
37
|
+
"import": "./dist/react.js",
|
|
38
|
+
"default": "./dist/react.js"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
11
41
|
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
42
|
+
"access": "public",
|
|
43
|
+
"provenance": true
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"effect": "4.0.0-rc.112",
|
|
47
|
+
"@effect/atom-react": "4.0.0-rc.112",
|
|
48
|
+
"react": "19.2.8"
|
|
49
|
+
},
|
|
50
|
+
"peerDependenciesMeta": {
|
|
51
|
+
"react": {
|
|
52
|
+
"optional": true
|
|
53
|
+
},
|
|
54
|
+
"@effect/atom-react": {
|
|
55
|
+
"optional": true
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@effect/vitest": "4.0.0-rc.112",
|
|
60
|
+
"@types/node": "24.10.1",
|
|
61
|
+
"@typescript/native": "npm:typescript@7.0.2",
|
|
62
|
+
"effect": "4.0.0-rc.112",
|
|
63
|
+
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
64
|
+
"vitest": "4.1.9",
|
|
65
|
+
"@effect/atom-react": "4.0.0-rc.112",
|
|
66
|
+
"react": "19.2.8",
|
|
67
|
+
"@types/react": "19.2.18"
|
|
68
|
+
},
|
|
69
|
+
"scripts": {
|
|
70
|
+
"build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc6 --project tsconfig.build.json",
|
|
71
|
+
"check": "biome check .",
|
|
72
|
+
"test": "vitest run",
|
|
73
|
+
"test:package": "node scripts/test-package.mjs",
|
|
74
|
+
"typecheck": "tsc --noEmit",
|
|
75
|
+
"typecheck:compat": "tsc6 --noEmit",
|
|
76
|
+
"ready": "pnpm check && pnpm typecheck && pnpm typecheck:compat && pnpm test && pnpm build && pnpm test:package"
|
|
13
77
|
}
|
|
14
|
-
}
|
|
78
|
+
}
|
package/src/draft.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Equal } from "effect";
|
|
2
|
+
import * as Atom from "effect/unstable/reactivity/Atom";
|
|
3
|
+
import * as AtomRef from "effect/unstable/reactivity/AtomRef";
|
|
4
|
+
|
|
5
|
+
type Entries = Readonly<Record<string, unknown>>;
|
|
6
|
+
|
|
7
|
+
interface Draft<Values> {
|
|
8
|
+
readonly baseline: Values;
|
|
9
|
+
readonly current: Values;
|
|
10
|
+
readonly settled: Entries;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const none: Entries = {};
|
|
14
|
+
|
|
15
|
+
const untouched = ({ baseline, current, settled }: Draft<Entries>, name: string): boolean =>
|
|
16
|
+
Equal.equals(current[name], baseline[name]) || (Object.hasOwn(settled, name) && Equal.equals(current[name], settled[name]));
|
|
17
|
+
|
|
18
|
+
const changed = (held: Draft<Entries>): boolean => Object.keys({ ...held.baseline, ...held.current }).some((name) => !untouched(held, name));
|
|
19
|
+
|
|
20
|
+
function merged<Values>(held: Draft<Values>, incoming: Values): Values;
|
|
21
|
+
function merged(held: Draft<Entries>, incoming: Entries): unknown {
|
|
22
|
+
const result: Record<string, unknown> = {};
|
|
23
|
+
for (const name of Object.keys({ ...held.current, ...incoming })) {
|
|
24
|
+
const source = untouched(held, name) ? incoming : held.current;
|
|
25
|
+
if (Object.hasOwn(source, name)) {
|
|
26
|
+
result[name] = source[name];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const matching = (current: Entries, submitted: Entries): Entries =>
|
|
33
|
+
Object.fromEntries(
|
|
34
|
+
Object.keys({ ...current, ...submitted })
|
|
35
|
+
.filter((name) => Equal.equals(current[name], submitted[name]))
|
|
36
|
+
.map((name) => [name, submitted[name]]),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
export const draft = <Values extends Entries>(initial: Values) => {
|
|
40
|
+
const state = AtomRef.make<Draft<Values>>({
|
|
41
|
+
baseline: initial,
|
|
42
|
+
current: initial,
|
|
43
|
+
settled: none,
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
dirty: Atom.readable((get) => {
|
|
47
|
+
get.addFinalizer(state.subscribe((value) => get.setSelf(changed(value))));
|
|
48
|
+
return changed(state.value);
|
|
49
|
+
}),
|
|
50
|
+
receive: (incoming: Values): void => {
|
|
51
|
+
state.update((held) => ({
|
|
52
|
+
baseline: incoming,
|
|
53
|
+
current: changed(held) ? merged(held, incoming) : incoming,
|
|
54
|
+
settled: none,
|
|
55
|
+
}));
|
|
56
|
+
},
|
|
57
|
+
revert: (): void => {
|
|
58
|
+
state.update((held) => ({
|
|
59
|
+
...held,
|
|
60
|
+
current: held.baseline,
|
|
61
|
+
settled: none,
|
|
62
|
+
}));
|
|
63
|
+
},
|
|
64
|
+
submission: () => {
|
|
65
|
+
const { baseline, current: submitted } = state.value;
|
|
66
|
+
return {
|
|
67
|
+
accept: (): void => {
|
|
68
|
+
state.update((held) =>
|
|
69
|
+
held.baseline === baseline ? { ...held, baseline: submitted, settled: none } : { ...held, settled: matching(held.current, submitted) },
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
values: submitted,
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
values: state.prop("current"),
|
|
76
|
+
};
|
|
77
|
+
};
|
package/src/form.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Effect, Equal, Option, Result, type Schema, SchemaParser } from "effect";
|
|
2
|
+
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
|
|
3
|
+
import * as Atom from "effect/unstable/reactivity/Atom";
|
|
4
|
+
import type * as AtomRef from "effect/unstable/reactivity/AtomRef";
|
|
5
|
+
import { draft } from "./draft.ts";
|
|
6
|
+
import { type FieldMessages, literalChoices, messageAt, messagesByField, noMessages } from "./messages.ts";
|
|
7
|
+
import { fromRef, propertyRef } from "./refs.ts";
|
|
8
|
+
import {
|
|
9
|
+
type Config,
|
|
10
|
+
checkOf,
|
|
11
|
+
choicesOf,
|
|
12
|
+
type Decoded,
|
|
13
|
+
type Encoded,
|
|
14
|
+
FieldFailure,
|
|
15
|
+
type Fields,
|
|
16
|
+
type Form,
|
|
17
|
+
fieldOf,
|
|
18
|
+
holder,
|
|
19
|
+
Invalid,
|
|
20
|
+
type Name,
|
|
21
|
+
type Submitter,
|
|
22
|
+
} from "./shape.ts";
|
|
23
|
+
import { statusOf } from "./status.ts";
|
|
24
|
+
|
|
25
|
+
export const make = <F extends Fields, A, E, R, ER>(schema: Schema.Struct<F>, config: Config<F, A, E, R, ER>): Form<F, A, E, ER> => {
|
|
26
|
+
const { initialValues, onSubmit, runtime } = config;
|
|
27
|
+
const debounce = config.debounce ?? "300 millis";
|
|
28
|
+
const checks: ReadonlyMap<string, unknown> = new Map(Object.entries(config.checks ?? {}));
|
|
29
|
+
const decode = SchemaParser.decodeUnknownEffect(schema, { errors: "all" });
|
|
30
|
+
|
|
31
|
+
const editing = draft(initialValues);
|
|
32
|
+
const { values } = editing;
|
|
33
|
+
const status = statusOf();
|
|
34
|
+
const held = new Map<string, AtomRef.AtomRef<unknown>>();
|
|
35
|
+
const refFor = (name: string): AtomRef.AtomRef<unknown> => {
|
|
36
|
+
const known = held.get(name);
|
|
37
|
+
if (known !== undefined) {
|
|
38
|
+
return known;
|
|
39
|
+
}
|
|
40
|
+
const made = propertyRef(holder(values), name);
|
|
41
|
+
held.set(name, made);
|
|
42
|
+
return made;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const offered = new Map<string, readonly unknown[]>();
|
|
46
|
+
for (const [name, member] of Object.entries(schema.fields)) {
|
|
47
|
+
const choices = literalChoices(member.ast);
|
|
48
|
+
if (choices !== undefined) {
|
|
49
|
+
offered.set(name, choices);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const valuesAtom = fromRef(values);
|
|
54
|
+
|
|
55
|
+
const decoded = runtime.atom((get) =>
|
|
56
|
+
decode(get(valuesAtom)).pipe(
|
|
57
|
+
Effect.match({
|
|
58
|
+
onFailure: (issue): Result.Result<Decoded<F>, FieldMessages> => Result.fail(messagesByField(issue)),
|
|
59
|
+
onSuccess: (value): Result.Result<Decoded<F>, FieldMessages> => Result.succeed(value),
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const schemaMessages = Atom.map(decoded, (result) =>
|
|
65
|
+
Option.match(AsyncResult.value(result), {
|
|
66
|
+
onNone: () => noMessages,
|
|
67
|
+
onSome: (either) => (Result.isFailure(either) ? either.failure : noMessages),
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const schemaError = Atom.family((name: string) => Atom.map(schemaMessages, (messages) => messageAt(messages, name)));
|
|
72
|
+
|
|
73
|
+
const checkError = Atom.family((name: string): Atom.Atom<string | undefined> => {
|
|
74
|
+
const check = checks.get(name);
|
|
75
|
+
if (check === undefined) {
|
|
76
|
+
return Atom.make((): string | undefined => undefined);
|
|
77
|
+
}
|
|
78
|
+
const current = fromRef(refFor(name));
|
|
79
|
+
const settled = Atom.debounce(current, debounce);
|
|
80
|
+
const answered = runtime.atom((get) => {
|
|
81
|
+
const input = get(settled);
|
|
82
|
+
return Effect.map(checkOf<R>(check)(input), (message) => ({
|
|
83
|
+
input,
|
|
84
|
+
message,
|
|
85
|
+
}));
|
|
86
|
+
});
|
|
87
|
+
return Atom.readable((get): string | undefined => {
|
|
88
|
+
const result = get(answered);
|
|
89
|
+
const value = get(current);
|
|
90
|
+
return AsyncResult.isSuccess(result) && !result.waiting && Equal.equals(result.value.input, value) ? result.value.message : undefined;
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const error = Atom.family((name: string) =>
|
|
95
|
+
Atom.readable((get): string | undefined => {
|
|
96
|
+
if (get(status.touched)[name] !== true && !get(status.submitted)) {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
return messageAt(get(status.failures), name) ?? get(schemaError(name)) ?? get(checkError(name));
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const noted =
|
|
104
|
+
(submitted: Encoded<F>) =>
|
|
105
|
+
(cause: E | FieldFailure): Effect.Effect<void> =>
|
|
106
|
+
Effect.sync(() => {
|
|
107
|
+
if (cause instanceof FieldFailure && Equal.equals(refFor(cause.path).value, new Map(Object.entries(submitted)).get(cause.path))) {
|
|
108
|
+
status.note(cause.path, cause.message);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const submitter: Submitter<Name<F>> = {
|
|
113
|
+
fail: (path, message) => Effect.fail(new FieldFailure({ message, path })),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const submit = runtime.fn<void>()(() =>
|
|
117
|
+
Effect.gen(function* () {
|
|
118
|
+
status.attempt();
|
|
119
|
+
const submission = editing.submission();
|
|
120
|
+
const submitted = submission.values;
|
|
121
|
+
const value = yield* decode(submitted).pipe(Effect.mapError((issue) => new Invalid({ messages: messagesByField(issue) })));
|
|
122
|
+
return yield* onSubmit(value, submitter, submitted).pipe(
|
|
123
|
+
Effect.tapError(noted(submitted)),
|
|
124
|
+
Effect.tap(() => Effect.sync(submission.accept)),
|
|
125
|
+
);
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
blur: status.touch,
|
|
131
|
+
change: (name, value) => {
|
|
132
|
+
refFor(name).set(value);
|
|
133
|
+
status.touch(name);
|
|
134
|
+
},
|
|
135
|
+
choices: <K extends Name<F>>(name: K) => choicesOf<Encoded<F>[K]>(offered.get(name)),
|
|
136
|
+
dirty: editing.dirty,
|
|
137
|
+
error,
|
|
138
|
+
field: <K extends Name<F>>(name: K) => fieldOf<Encoded<F>[K]>(refFor(name)),
|
|
139
|
+
receive: editing.receive,
|
|
140
|
+
revert: editing.revert,
|
|
141
|
+
submit,
|
|
142
|
+
submitting: Atom.map(submit, AsyncResult.isWaiting),
|
|
143
|
+
values,
|
|
144
|
+
};
|
|
145
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { make } from "./form.ts";
|
|
2
|
+
export type { FieldMessages } from "./messages.ts";
|
|
3
|
+
export { emptyAsNull } from "./optional.ts";
|
|
4
|
+
export type {
|
|
5
|
+
Checks,
|
|
6
|
+
Config,
|
|
7
|
+
Decoded,
|
|
8
|
+
Encoded,
|
|
9
|
+
Fields,
|
|
10
|
+
Form,
|
|
11
|
+
Name,
|
|
12
|
+
Services,
|
|
13
|
+
Submitter,
|
|
14
|
+
} from "./shape.ts";
|
|
15
|
+
export { FieldFailure, Invalid } from "./shape.ts";
|
package/src/messages.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { SchemaAST, SchemaIssue, type StandardSchema } from "effect";
|
|
2
|
+
|
|
3
|
+
export type FieldMessages = Readonly<Record<string, string>>;
|
|
4
|
+
|
|
5
|
+
export const noMessages: FieldMessages = {};
|
|
6
|
+
|
|
7
|
+
const REQUIRED = "Required";
|
|
8
|
+
|
|
9
|
+
const MINIMUM_LENGTH = "effect/schema/isMinLength";
|
|
10
|
+
|
|
11
|
+
const refusesEmpty = (check: SchemaAST.Filter<unknown>): boolean => {
|
|
12
|
+
const representation = check.annotations?.representation;
|
|
13
|
+
if (representation === undefined || representation.id !== MINIMUM_LENGTH) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
const payload = representation.payload;
|
|
17
|
+
if (typeof payload !== "object" || payload === null || !("minLength" in payload)) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
return payload.minLength === 1;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const checkHook: SchemaIssue.CheckHook = (issue) => SchemaIssue.defaultCheckHook(issue) ?? (refusesEmpty(issue.filter) ? REQUIRED : undefined);
|
|
24
|
+
|
|
25
|
+
const formatIssue = SchemaIssue.makeFormatterStandardSchemaV1({ checkHook });
|
|
26
|
+
|
|
27
|
+
const segmentKey = (segment: PropertyKey | StandardSchema.StandardSchemaV1.PathSegment): string =>
|
|
28
|
+
typeof segment === "object" ? String(segment.key) : String(segment);
|
|
29
|
+
|
|
30
|
+
export const messagesByField = (issue: SchemaIssue.Issue): FieldMessages => {
|
|
31
|
+
const messages: Record<string, string> = Object.create(null);
|
|
32
|
+
for (const entry of formatIssue(issue).issues) {
|
|
33
|
+
const head = entry.path?.[0];
|
|
34
|
+
if (head === undefined) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const name = segmentKey(head);
|
|
38
|
+
if (!Object.hasOwn(messages, name)) {
|
|
39
|
+
messages[name] = entry.message;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return messages;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const withoutField = (messages: FieldMessages, name: string): FieldMessages => {
|
|
46
|
+
if (!Object.hasOwn(messages, name)) {
|
|
47
|
+
return messages;
|
|
48
|
+
}
|
|
49
|
+
const next: Record<string, string> = Object.create(null);
|
|
50
|
+
for (const [key, message] of Object.entries(messages)) {
|
|
51
|
+
if (key !== name) {
|
|
52
|
+
next[key] = message;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return next;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const literalChoices = (input: SchemaAST.AST): readonly SchemaAST.LiteralValue[] | undefined => {
|
|
59
|
+
const ast = SchemaAST.toEncoded(input);
|
|
60
|
+
if (ast._tag !== "Union") {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
const choices: SchemaAST.LiteralValue[] = [];
|
|
64
|
+
for (const member of ast.types) {
|
|
65
|
+
if (member._tag !== "Literal") {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
choices.push(member.literal);
|
|
69
|
+
}
|
|
70
|
+
return choices;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const messageAt = (messages: FieldMessages, name: string): string | undefined => (Object.hasOwn(messages, name) ? messages[name] : undefined);
|
package/src/optional.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Schema, SchemaGetter } from "effect";
|
|
2
|
+
|
|
3
|
+
export const emptyAsNull = <S extends Schema.Constraint & Schema.Top>(
|
|
4
|
+
inner: S,
|
|
5
|
+
): Schema.Codec<S["Type"] | null, S["Encoded"] | "", S["DecodingServices"], S["EncodingServices"]> =>
|
|
6
|
+
Schema.Union([Schema.Literal(""), inner]).pipe(
|
|
7
|
+
Schema.decodeTo(Schema.NullOr(Schema.toType(inner)), {
|
|
8
|
+
decode: SchemaGetter.transform((value) => (value === "" ? null : value)),
|
|
9
|
+
encode: SchemaGetter.transform((value) => (value === null ? "" : value)),
|
|
10
|
+
}),
|
|
11
|
+
);
|
package/src/react.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useAtomRef, useAtomSet, useAtomValue } from "@effect/atom-react";
|
|
2
|
+
import type * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
|
|
3
|
+
import { useCallback, useMemo } from "react";
|
|
4
|
+
import type { Encoded, FieldFailure, Fields, Form, Invalid, Name } from "./shape.ts";
|
|
5
|
+
|
|
6
|
+
export interface Field<F extends Fields, K extends Name<F>> {
|
|
7
|
+
readonly choices: readonly Encoded<F>[K][] | undefined;
|
|
8
|
+
readonly error: string | undefined;
|
|
9
|
+
readonly name: K;
|
|
10
|
+
readonly onBlur: () => void;
|
|
11
|
+
readonly onChange: (value: Encoded<F>[K]) => void;
|
|
12
|
+
readonly value: Encoded<F>[K];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const useField = <F extends Fields, A, E, ER, K extends Name<F>>(form: Form<F, A, E, ER>, name: K): Field<F, K> => {
|
|
16
|
+
const value = useAtomRef(form.field(name));
|
|
17
|
+
const error = useAtomValue(form.error(name));
|
|
18
|
+
const onChange = useCallback((next: Encoded<F>[K]) => form.change(name, next), [form, name]);
|
|
19
|
+
const onBlur = useCallback(() => form.blur(name), [form, name]);
|
|
20
|
+
const choices = useMemo(() => form.choices(name), [form, name]);
|
|
21
|
+
return { choices, error, name, onBlur, onChange, value };
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export interface Submit<A, E> {
|
|
25
|
+
readonly result: AsyncResult.AsyncResult<A, E>;
|
|
26
|
+
readonly run: () => void;
|
|
27
|
+
readonly submitting: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const useSubmit = <F extends Fields, A, E, ER>(form: Form<F, A, E, ER>): Submit<A, E | ER | FieldFailure | Invalid> => {
|
|
31
|
+
const result = useAtomValue(form.submit);
|
|
32
|
+
const set = useAtomSet(form.submit);
|
|
33
|
+
const run = useCallback(() => set(), [set]);
|
|
34
|
+
return { result, run, submitting: result.waiting };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const useDirty = <F extends Fields, A, E, ER>(form: Form<F, A, E, ER>): boolean => useAtomValue(form.dirty);
|
package/src/refs.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Equal, Hash } from "effect";
|
|
2
|
+
import * as Atom from "effect/unstable/reactivity/Atom";
|
|
3
|
+
import * as AtomRef from "effect/unstable/reactivity/AtomRef";
|
|
4
|
+
|
|
5
|
+
export const fromRef = <A>(ref: AtomRef.ReadonlyRef<A>): Atom.Atom<A> =>
|
|
6
|
+
Atom.readable((get) => {
|
|
7
|
+
get.addFinalizer(ref.subscribe((value) => get.setSelf(value)));
|
|
8
|
+
return ref.value;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
// Native prop refs retain removed keys; form fields must observe their absence.
|
|
12
|
+
export const propertyRef = <A, K extends keyof A>(parent: AtomRef.AtomRef<A>, name: K): AtomRef.AtomRef<A[K]> => {
|
|
13
|
+
const read = parent.map((value) => value[name]);
|
|
14
|
+
const write = parent.prop(name);
|
|
15
|
+
const ref: AtomRef.AtomRef<A[K]> = {
|
|
16
|
+
[AtomRef.TypeId]: AtomRef.TypeId,
|
|
17
|
+
[Equal.symbol]: (other) => Equal.equals(read, other),
|
|
18
|
+
[Hash.symbol]: () => Hash.hash(read),
|
|
19
|
+
key: read.key,
|
|
20
|
+
get value() {
|
|
21
|
+
return read.value;
|
|
22
|
+
},
|
|
23
|
+
subscribe: (listener) => read.subscribe(listener),
|
|
24
|
+
map: (transform) => read.map(transform),
|
|
25
|
+
prop: (key) => propertyRef(ref, key),
|
|
26
|
+
set: (value) => {
|
|
27
|
+
write.set(value);
|
|
28
|
+
return ref;
|
|
29
|
+
},
|
|
30
|
+
update: (transform) => ref.set(transform(read.value)),
|
|
31
|
+
};
|
|
32
|
+
return ref;
|
|
33
|
+
};
|
package/src/shape.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Data, type Duration, type Effect, type Schema, type Scope } from "effect";
|
|
2
|
+
import type * as Atom from "effect/unstable/reactivity/Atom";
|
|
3
|
+
import type * as AtomRef from "effect/unstable/reactivity/AtomRef";
|
|
4
|
+
import type { AtomRegistry } from "effect/unstable/reactivity/AtomRegistry";
|
|
5
|
+
import type { Reactivity } from "effect/unstable/reactivity/Reactivity";
|
|
6
|
+
import type { FieldMessages } from "./messages.ts";
|
|
7
|
+
|
|
8
|
+
export type Fields = Schema.Struct.Fields;
|
|
9
|
+
|
|
10
|
+
export type Encoded<F extends Fields> = Schema.Struct.Encoded<F>;
|
|
11
|
+
|
|
12
|
+
export type Decoded<F extends Fields> = Schema.Struct.Type<F>;
|
|
13
|
+
|
|
14
|
+
export type Name<F extends Fields> = keyof Encoded<F> & string;
|
|
15
|
+
|
|
16
|
+
export type Services<F extends Fields, R> = R | Schema.Struct.DecodingServices<F>;
|
|
17
|
+
|
|
18
|
+
export class FieldFailure extends Data.TaggedError("FieldFailure")<{
|
|
19
|
+
readonly message: string;
|
|
20
|
+
readonly path: string;
|
|
21
|
+
}> {}
|
|
22
|
+
|
|
23
|
+
export class Invalid extends Data.TaggedError("Invalid")<{
|
|
24
|
+
readonly messages: FieldMessages;
|
|
25
|
+
}> {}
|
|
26
|
+
|
|
27
|
+
export interface Submitter<N extends string> {
|
|
28
|
+
readonly fail: (path: N, message: string) => Effect.Effect<never, FieldFailure>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type Checks<F extends Fields, R> = {
|
|
32
|
+
readonly [K in Name<F>]?: (value: Encoded<F>[K]) => Effect.Effect<string | undefined, never, R>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export interface Config<F extends Fields, A, E, R, ER> {
|
|
36
|
+
readonly checks?: Checks<F, R>;
|
|
37
|
+
readonly debounce?: Duration.Input;
|
|
38
|
+
readonly initialValues: Encoded<F>;
|
|
39
|
+
readonly onSubmit: (
|
|
40
|
+
values: Decoded<F>,
|
|
41
|
+
submitter: Submitter<Name<F>>,
|
|
42
|
+
submitted: Encoded<F>,
|
|
43
|
+
) => Effect.Effect<A, E, R | Scope.Scope | AtomRegistry | Reactivity>;
|
|
44
|
+
readonly runtime: Atom.AtomRuntime<Services<F, R>, ER>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface Form<F extends Fields, A, E, ER> {
|
|
48
|
+
readonly blur: (name: Name<F>) => void;
|
|
49
|
+
readonly change: <K extends Name<F>>(name: K, value: Encoded<F>[K]) => void;
|
|
50
|
+
readonly choices: <K extends Name<F>>(name: K) => readonly Encoded<F>[K][] | undefined;
|
|
51
|
+
readonly dirty: Atom.Atom<boolean>;
|
|
52
|
+
readonly error: (name: Name<F>) => Atom.Atom<string | undefined>;
|
|
53
|
+
readonly field: <K extends Name<F>>(name: K) => AtomRef.AtomRef<Encoded<F>[K]>;
|
|
54
|
+
readonly receive: (values: Encoded<F>) => void;
|
|
55
|
+
readonly revert: () => void;
|
|
56
|
+
readonly submit: Atom.AtomResultFn<void, A, E | ER | FieldFailure | Invalid>;
|
|
57
|
+
readonly submitting: Atom.Atom<boolean>;
|
|
58
|
+
readonly values: AtomRef.AtomRef<Encoded<F>>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type Holder = AtomRef.AtomRef<Readonly<Record<string, unknown>>>;
|
|
62
|
+
|
|
63
|
+
export function holder(values: object): Holder;
|
|
64
|
+
export function holder(values: object): unknown {
|
|
65
|
+
return values;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function fieldOf<Value>(ref: AtomRef.AtomRef<unknown>): AtomRef.AtomRef<Value>;
|
|
69
|
+
export function fieldOf(ref: AtomRef.AtomRef<unknown>): unknown {
|
|
70
|
+
return ref;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function choicesOf<Value>(choices: readonly unknown[] | undefined): readonly Value[] | undefined;
|
|
74
|
+
export function choicesOf(choices: readonly unknown[] | undefined): unknown {
|
|
75
|
+
return choices;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function checkOf<R>(check: unknown): (value: unknown) => Effect.Effect<string | undefined, never, R>;
|
|
79
|
+
export function checkOf(check: unknown): unknown {
|
|
80
|
+
return check;
|
|
81
|
+
}
|