@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/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;AA0BnF,MAAM,OAAO,YAAa,SAAQ,IAAI,CAAC,WAAW,CAAC,cAAc,CAI/D;CAAG;AAEL,MAAM,OAAO,OAAQ,SAAQ,IAAI,CAAC,WAAW,CAAC,SAAS,CAErD;CAAG;AA0CL,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"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as Atom from "effect/reactivity/Atom";
|
|
2
|
+
import { type KeySelectors, type PathMessage } from "./paths.ts";
|
|
3
|
+
import type { Path } from "./shape.ts";
|
|
4
|
+
export declare function statusOf(): {
|
|
5
|
+
attempt: () => void;
|
|
6
|
+
failures: Atom.Atom<Readonly<Record<string, string>>>;
|
|
7
|
+
note: (input: string | Path, message: string) => void;
|
|
8
|
+
paths: Atom.Atom<readonly PathMessage[]>;
|
|
9
|
+
rebase: (previous: unknown, next: unknown, keys: KeySelectors) => void;
|
|
10
|
+
submitted: Atom.Atom<boolean>;
|
|
11
|
+
touch: (name: string) => void;
|
|
12
|
+
touched: Atom.Atom<Readonly<Record<string, true>>>;
|
|
13
|
+
};
|
|
14
|
+
//# sourceMappingURL=statusOf.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"statusOf.d.ts","sourceRoot":"","sources":["../src/statusOf.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,IAAI,MAAM,wBAAwB,CAAC;AAG/C,OAAO,EAAU,KAAK,YAAY,EAAE,KAAK,WAAW,EAAgB,MAAM,YAAY,CAAC;AAEvF,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAQvC,wBAAgB,QAAQ;mBAQT,IAAI;;kBAgBH,MAAM,GAAG,IAAI,WAAW,MAAM,KAAG,IAAI;;uBAQhC,OAAO,QAAQ,OAAO,QAAQ,YAAY,KAAG,IAAI;;kBAUtD,MAAM,KAAG,IAAI;;EAa5B"}
|
package/dist/statusOf.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Equal } from "effect";
|
|
2
|
+
import * as Atom from "effect/reactivity/Atom";
|
|
3
|
+
import * as AtomRef from "effect/reactivity/AtomRef";
|
|
4
|
+
import { asPath, retainedPath } from "./paths.js";
|
|
5
|
+
import { fromRef } from "./refs.js";
|
|
6
|
+
export function statusOf() {
|
|
7
|
+
const state = AtomRef.make({
|
|
8
|
+
failures: [],
|
|
9
|
+
submitted: false,
|
|
10
|
+
touched: {},
|
|
11
|
+
});
|
|
12
|
+
const paths = fromRef(state.prop("failures"));
|
|
13
|
+
return {
|
|
14
|
+
attempt: () => {
|
|
15
|
+
state.update((current) => ({
|
|
16
|
+
...current,
|
|
17
|
+
failures: [],
|
|
18
|
+
submitted: true,
|
|
19
|
+
}));
|
|
20
|
+
},
|
|
21
|
+
failures: Atom.map(paths, (entries) => {
|
|
22
|
+
const messages = Object.create(null);
|
|
23
|
+
for (const { path, message } of entries) {
|
|
24
|
+
if (!Object.hasOwn(messages, path[0])) {
|
|
25
|
+
messages[path[0]] = message;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return messages;
|
|
29
|
+
}),
|
|
30
|
+
note: (input, message) => {
|
|
31
|
+
const path = asPath(input);
|
|
32
|
+
state.update((current) => ({
|
|
33
|
+
...current,
|
|
34
|
+
failures: [...current.failures.filter((entry) => !Equal.equals(entry.path, path)), { message, path }],
|
|
35
|
+
}));
|
|
36
|
+
},
|
|
37
|
+
paths,
|
|
38
|
+
rebase: (previous, next, keys) => {
|
|
39
|
+
state.update((current) => ({
|
|
40
|
+
...current,
|
|
41
|
+
failures: current.failures.flatMap((entry) => {
|
|
42
|
+
const path = retainedPath(entry.path, previous, next, keys);
|
|
43
|
+
return path === undefined ? [] : [{ ...entry, path }];
|
|
44
|
+
}),
|
|
45
|
+
}));
|
|
46
|
+
},
|
|
47
|
+
submitted: fromRef(state.prop("submitted")),
|
|
48
|
+
touch: (name) => {
|
|
49
|
+
state.update((current) => current.touched[name] === true && !current.failures.some((entry) => entry.path.length === 1 && entry.path[0] === name)
|
|
50
|
+
? current
|
|
51
|
+
: {
|
|
52
|
+
...current,
|
|
53
|
+
failures: current.failures.filter((entry) => entry.path.length !== 1 || entry.path[0] !== name),
|
|
54
|
+
touched: { ...current.touched, [name]: true },
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
touched: fromRef(state.prop("touched")),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=statusOf.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"statusOf.js","sourceRoot":"","sources":["../src/statusOf.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { type Holder } from "./shape.ts";
|
|
3
|
+
import type { statusOf } from "./statusOf.ts";
|
|
4
|
+
export declare function trackRejections(values: Holder, selectors: Readonly<Record<string, unknown>> | undefined, status: ReturnType<typeof statusOf>): (submitted: unknown) => (cause: unknown) => Effect.Effect<void>;
|
|
5
|
+
//# sourceMappingURL=trackRejections.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"trackRejections.d.ts","sourceRoot":"","sources":["../src/trackRejections.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,OAAO,EAAgB,KAAK,MAAM,EAAE,MAAM,YAAY,CAAC;AACvD,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAE9C,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,GAAG,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,OAAO,QAAQ,CAAC,eAOzH,OAAO,aACjB,OAAO,KAAG,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,CAStC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { asPath, keySelector, retainedPath } from "./paths.js";
|
|
3
|
+
import { FieldFailure } from "./shape.js";
|
|
4
|
+
export function trackRejections(values, selectors, status) {
|
|
5
|
+
const keys = new Map(Object.entries(selectors ?? {}).map(([name, select]) => [name, keySelector(select)]));
|
|
6
|
+
let previous = values.value;
|
|
7
|
+
values.subscribe((next) => {
|
|
8
|
+
status.rebase(previous, next, keys);
|
|
9
|
+
previous = next;
|
|
10
|
+
});
|
|
11
|
+
return (submitted) => (cause) => Effect.sync(() => {
|
|
12
|
+
if (cause instanceof FieldFailure) {
|
|
13
|
+
const path = retainedPath(cause.segments ?? asPath(cause.path), submitted, values.value, keys);
|
|
14
|
+
if (path !== undefined) {
|
|
15
|
+
status.note(path, cause.message);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=trackRejections.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"trackRejections.js","sourceRoot":"","sources":["../src/trackRejections.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC/D,OAAO,EAAE,YAAY,EAAe,MAAM,YAAY,CAAC;AAGvD,MAAM,UAAU,eAAe,CAAC,MAAc,EAAE,SAAwD,EAAE,MAAmC;IAC5I,MAAM,IAAI,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,SAAS,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,MAAM,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;IAC3G,IAAI,QAAQ,GAAG,MAAM,CAAC,KAAK,CAAC;IAC5B,MAAM,CAAC,SAAS,CAAC,CAAC,IAAI,EAAE,EAAE;QACzB,MAAM,CAAC,MAAM,CAAC,QAAQ,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;QACpC,QAAQ,GAAG,IAAI,CAAC;IACjB,CAAC,CAAC,CAAC;IACH,OAAO,CAAC,SAAkB,EAAE,EAAE,CAC7B,CAAC,KAAc,EAAuB,EAAE,CACvC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE;QAChB,IAAI,KAAK,YAAY,YAAY,EAAE,CAAC;YACnC,MAAM,IAAI,GAAG,YAAY,CAAC,KAAK,CAAC,QAAQ,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,MAAM,CAAC,KAAK,EAAE,IAAI,CAAC,CAAC;YAC/F,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;gBACxB,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;YAClC,CAAC;QACF,CAAC;IACF,CAAC,CAAC,CAAC;AACN,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,10 +1,84 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shivaedev/form",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
3
|
+
"version": "0.6.2",
|
|
4
|
+
"description": "Schema-derived Effect atom forms with optional React hooks",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"effect"
|
|
7
|
+
],
|
|
8
|
+
"homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/form#readme",
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/ShivaeDev/platform/issues"
|
|
11
|
+
},
|
|
6
12
|
"repository": {
|
|
7
13
|
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/ShivaeDev/platform.git"
|
|
14
|
+
"url": "git+https://github.com/ShivaeDev/platform.git",
|
|
15
|
+
"directory": "packages/form"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"sideEffects": false,
|
|
19
|
+
"type": "module",
|
|
20
|
+
"imports": {
|
|
21
|
+
"#test/*": "./src/test-support/*",
|
|
22
|
+
"#*.ts": {
|
|
23
|
+
"source": "./src/*.ts",
|
|
24
|
+
"types": "./dist/*.d.ts",
|
|
25
|
+
"default": "./dist/*.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
"./*.ts": {
|
|
30
|
+
"source": "./src/*.ts",
|
|
31
|
+
"types": "./dist/*.d.ts",
|
|
32
|
+
"default": "./dist/*.js"
|
|
33
|
+
},
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"src",
|
|
39
|
+
"!src/**/*.test.ts",
|
|
40
|
+
"!src/**/*.spec.ts",
|
|
41
|
+
"!src/**/test-support",
|
|
42
|
+
"CHANGELOG.md",
|
|
43
|
+
"README.md"
|
|
44
|
+
],
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@effect/atom-react": "4.0.5",
|
|
47
|
+
"@effect/vitest": "4.0.5",
|
|
48
|
+
"@shivaedev/quality": "0.41.1",
|
|
49
|
+
"@shivaedev/test": "0.9.0",
|
|
50
|
+
"@shivaedev/test-story": "0.20.1",
|
|
51
|
+
"@types/node": "24.19.2",
|
|
52
|
+
"@types/react": "19.3.0",
|
|
53
|
+
"effect": "4.0.5",
|
|
54
|
+
"react": "19.3.1",
|
|
55
|
+
"typescript": "7.0.2",
|
|
56
|
+
"vitest": "5.0.5"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"@effect/atom-react": "4.0.5",
|
|
60
|
+
"effect": "4.0.5",
|
|
61
|
+
"react": "19.3.1"
|
|
62
|
+
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"@effect/atom-react": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"react": {
|
|
68
|
+
"optional": true
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"engines": {
|
|
72
|
+
"node": ">=24"
|
|
73
|
+
},
|
|
74
|
+
"publishConfig": {
|
|
75
|
+
"access": "public",
|
|
76
|
+
"provenance": true
|
|
77
|
+
},
|
|
78
|
+
"scripts": {
|
|
79
|
+
"build": "node --eval \"import('node:fs').then(({ rmSync }) => rmSync('dist', { force: true, recursive: true }))\" && tsc --project tsconfig.build.json",
|
|
80
|
+
"ready": "pnpm typecheck && pnpm test && pnpm build",
|
|
81
|
+
"test": "vitest run",
|
|
82
|
+
"typecheck": "tsc --noEmit"
|
|
9
83
|
}
|
|
10
84
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useAtomRef, useAtomSet, useAtomValue } from "@effect/atom-react";
|
|
2
|
+
import type * as AsyncResult from "effect/reactivity/AsyncResult";
|
|
3
|
+
import { createContext, useCallback, useContext, useMemo } from "react";
|
|
4
|
+
import type { DraftSession } from "#draftSession.ts";
|
|
5
|
+
import type { DraftStorage, PersistedDraftConfig } from "#persistence.ts";
|
|
6
|
+
import type { Encoded, FieldFailure, Fields, Form, Invalid, Name } from "#shape.ts";
|
|
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 {
|
|
17
|
+
const session = useContext(DraftSessionContext);
|
|
18
|
+
return config ? { ...config, session: session ?? config.session, storage: config.storage ?? browserStorage } : undefined;
|
|
19
|
+
}
|
|
20
|
+
export interface Field<TFields extends Fields, TKey extends Name<TFields>> {
|
|
21
|
+
readonly choices: readonly Encoded<TFields>[TKey][] | undefined;
|
|
22
|
+
readonly error: string | undefined;
|
|
23
|
+
readonly name: TKey;
|
|
24
|
+
readonly onBlur: () => void;
|
|
25
|
+
readonly onChange: (value: Encoded<TFields>[TKey]) => void;
|
|
26
|
+
readonly value: Encoded<TFields>[TKey];
|
|
27
|
+
}
|
|
28
|
+
export function useField<TFields extends Fields, A, E, TRuntimeError, TKey extends Name<TFields>>(
|
|
29
|
+
form: Form<TFields, A, E, TRuntimeError>,
|
|
30
|
+
name: TKey,
|
|
31
|
+
): Field<TFields, TKey> {
|
|
32
|
+
const value = useAtomRef(form.field(name));
|
|
33
|
+
const error = useAtomValue(form.error(name));
|
|
34
|
+
const onChange = useCallback((next: Encoded<TFields>[TKey]) => form.change(name, next), [form, name]);
|
|
35
|
+
const onBlur = useCallback(() => form.blur(name), [form, name]);
|
|
36
|
+
const choices = useMemo(() => form.choices(name), [form, name]);
|
|
37
|
+
return { choices, error, name, onBlur, onChange, value };
|
|
38
|
+
}
|
|
39
|
+
export interface Submit<A, E> {
|
|
40
|
+
readonly result: AsyncResult.AsyncResult<A, E>;
|
|
41
|
+
readonly run: () => void;
|
|
42
|
+
readonly submitting: boolean;
|
|
43
|
+
}
|
|
44
|
+
export function useSubmit<TFields extends Fields, A, E, TRuntimeError>(
|
|
45
|
+
form: Form<TFields, A, E, TRuntimeError>,
|
|
46
|
+
): Submit<A, E | TRuntimeError | FieldFailure | Invalid> {
|
|
47
|
+
const result = useAtomValue(form.submit);
|
|
48
|
+
const set = useAtomSet(form.submit);
|
|
49
|
+
const run = useCallback(() => set(), [set]);
|
|
50
|
+
return { result, run, submitting: result.waiting };
|
|
51
|
+
}
|
|
52
|
+
export function useDirty<TFields extends Fields, A, E, TRuntimeError>(form: Form<TFields, A, E, TRuntimeError>): boolean {
|
|
53
|
+
return useAtomValue(form.dirty);
|
|
54
|
+
}
|
package/src/draft.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Equal } from "effect";
|
|
2
|
+
import * as Atom from "effect/reactivity/Atom";
|
|
3
|
+
import * as AtomRef from "effect/reactivity/AtomRef";
|
|
4
|
+
import type { Persistence } from "./persistence.ts";
|
|
5
|
+
|
|
6
|
+
type Entries = Readonly<Record<string, unknown>>;
|
|
7
|
+
|
|
8
|
+
interface Draft<TValues> {
|
|
9
|
+
readonly baseline: TValues;
|
|
10
|
+
readonly current: TValues;
|
|
11
|
+
readonly settled: Entries;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const none: Entries = {};
|
|
15
|
+
|
|
16
|
+
function untouched({ baseline, current, settled }: Draft<Entries>, name: string): boolean {
|
|
17
|
+
return Equal.equals(current[name], baseline[name]) || (Object.hasOwn(settled, name) && Equal.equals(current[name], settled[name]));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function changed(held: Draft<Entries>): boolean {
|
|
21
|
+
return Object.keys({ ...held.baseline, ...held.current }).some((name) => !untouched(held, name));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function merged<TValues>(held: Draft<TValues>, incoming: TValues): TValues;
|
|
25
|
+
function merged(held: Draft<Entries>, incoming: Entries): unknown {
|
|
26
|
+
const result: Record<string, unknown> = {};
|
|
27
|
+
for (const name of Object.keys({ ...held.current, ...incoming })) {
|
|
28
|
+
const source = untouched(held, name) ? incoming : held.current;
|
|
29
|
+
if (Object.hasOwn(source, name)) {
|
|
30
|
+
result[name] = source[name];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function matching(current: Entries, submitted: Entries): Entries {
|
|
37
|
+
return Object.fromEntries(
|
|
38
|
+
Object.keys({ ...current, ...submitted })
|
|
39
|
+
.filter((name) => Equal.equals(current[name], submitted[name]))
|
|
40
|
+
.map((name) => [name, submitted[name]]),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function draft<TValues extends Entries>(initial: TValues, persistence?: Persistence<TValues>) {
|
|
45
|
+
const restored = persistence?.read();
|
|
46
|
+
const loaded = {
|
|
47
|
+
baseline: restored?.baseline ?? initial,
|
|
48
|
+
current: restored?.values ?? initial,
|
|
49
|
+
settled: restored ? Object.fromEntries(Object.entries(restored.values).filter(([name]) => !restored.edited.includes(name))) : none,
|
|
50
|
+
};
|
|
51
|
+
const state = AtomRef.make<Draft<TValues>>({
|
|
52
|
+
baseline: initial,
|
|
53
|
+
current: restored ? merged(loaded, initial) : initial,
|
|
54
|
+
settled: none,
|
|
55
|
+
});
|
|
56
|
+
if (persistence) {
|
|
57
|
+
state.subscribe((value) => {
|
|
58
|
+
if (changed(value)) {
|
|
59
|
+
const edited = Object.keys({ ...value.baseline, ...value.current }).filter((name) => !untouched(value, name));
|
|
60
|
+
persistence.write({ ...value.baseline, ...value.settled }, value.current, edited);
|
|
61
|
+
} else {
|
|
62
|
+
persistence.clear();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
dirty: Atom.readable((get) => {
|
|
68
|
+
get.addFinalizer(state.subscribe((value) => get.setSelf(changed(value))));
|
|
69
|
+
return changed(state.value);
|
|
70
|
+
}),
|
|
71
|
+
receive: (incoming: TValues): void => {
|
|
72
|
+
state.update((held) => ({
|
|
73
|
+
baseline: incoming,
|
|
74
|
+
current: changed(held) ? merged(held, incoming) : incoming,
|
|
75
|
+
settled: none,
|
|
76
|
+
}));
|
|
77
|
+
},
|
|
78
|
+
revert: (): void => {
|
|
79
|
+
state.update((held) => ({
|
|
80
|
+
...held,
|
|
81
|
+
current: held.baseline,
|
|
82
|
+
settled: none,
|
|
83
|
+
}));
|
|
84
|
+
},
|
|
85
|
+
submission: () => {
|
|
86
|
+
const { baseline, current: submitted } = state.value;
|
|
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
|
+
);
|
|
92
|
+
},
|
|
93
|
+
values: submitted,
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
values: state.prop("current"),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface DraftSession {
|
|
2
|
+
readonly active: boolean;
|
|
3
|
+
readonly drop: () => void;
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly track: (key: string, clear: () => void) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function draftSession(id: string): DraftSession {
|
|
9
|
+
const drafts = new Map<string, () => void>();
|
|
10
|
+
let active = true;
|
|
11
|
+
return {
|
|
12
|
+
get active() {
|
|
13
|
+
return active;
|
|
14
|
+
},
|
|
15
|
+
drop: () => {
|
|
16
|
+
active = false;
|
|
17
|
+
for (const clear of drafts.values()) {
|
|
18
|
+
clear();
|
|
19
|
+
}
|
|
20
|
+
drafts.clear();
|
|
21
|
+
},
|
|
22
|
+
id,
|
|
23
|
+
track: (key, clear) => {
|
|
24
|
+
if (active) {
|
|
25
|
+
drafts.set(key, clear);
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Schema, SchemaGetter } from "effect";
|
|
2
|
+
|
|
3
|
+
export function emptyAsNull<TSchema extends Schema.Constraint & Schema.Top>(
|
|
4
|
+
inner: TSchema,
|
|
5
|
+
): Schema.Codec<TSchema["Type"] | null, TSchema["Encoded"] | "", TSchema["DecodingServices"], TSchema["EncodingServices"]> {
|
|
6
|
+
return 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
|
+
);
|
|
12
|
+
}
|
package/src/form.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { Effect, Equal, Result, type Schema, type SchemaIssue, SchemaParser } 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 { draft } from "./draft.ts";
|
|
6
|
+
import { literalChoices, messageAt, messagesByField, schemaFeedback } from "./messages.ts";
|
|
7
|
+
import { pathMessageAt } from "./paths.ts";
|
|
8
|
+
import { persistence } from "./persistence.ts";
|
|
9
|
+
import { fromRef, propertyRef } from "./refs.ts";
|
|
10
|
+
import {
|
|
11
|
+
type Config,
|
|
12
|
+
checkOf,
|
|
13
|
+
choicesOf,
|
|
14
|
+
type Decoded,
|
|
15
|
+
type Encoded,
|
|
16
|
+
FieldFailure,
|
|
17
|
+
type Fields,
|
|
18
|
+
type Form,
|
|
19
|
+
fieldOf,
|
|
20
|
+
holder,
|
|
21
|
+
Invalid,
|
|
22
|
+
type Name,
|
|
23
|
+
type Submitter,
|
|
24
|
+
} from "./shape.ts";
|
|
25
|
+
import { statusOf } from "./statusOf.ts";
|
|
26
|
+
import { trackRejections } from "./trackRejections.ts";
|
|
27
|
+
|
|
28
|
+
export function form<TFields extends Fields, TValue, TError, TServices, TRuntimeError>(
|
|
29
|
+
schema: Schema.Struct<TFields>,
|
|
30
|
+
config: Config<TFields, TValue, TError, TServices, TRuntimeError>,
|
|
31
|
+
): Form<TFields, TValue, TError, TRuntimeError> {
|
|
32
|
+
const { initialValues, onSubmit, runtime } = config;
|
|
33
|
+
const debounce = config.debounce ?? "300 millis";
|
|
34
|
+
const checks: ReadonlyMap<string, unknown> = new Map(Object.entries(config.checks ?? {}));
|
|
35
|
+
const decode = SchemaParser.decodeUnknownEffect(schema, { errors: "all" });
|
|
36
|
+
|
|
37
|
+
const editing = draft(initialValues, config.persistedDraft ? persistence(schema, config.persistedDraft) : undefined);
|
|
38
|
+
const { values } = editing;
|
|
39
|
+
const status = statusOf();
|
|
40
|
+
const noted = trackRejections(holder(values), config.arrayKeys, status);
|
|
41
|
+
const held = new Map<string, AtomRef.AtomRef<unknown>>();
|
|
42
|
+
function refFor(name: string): AtomRef.AtomRef<unknown> {
|
|
43
|
+
const known = held.get(name);
|
|
44
|
+
if (known !== undefined) {
|
|
45
|
+
return known;
|
|
46
|
+
}
|
|
47
|
+
const made = propertyRef(holder(values), name);
|
|
48
|
+
held.set(name, made);
|
|
49
|
+
return made;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const offered = new Map<string, readonly unknown[]>();
|
|
53
|
+
for (const [name, member] of Object.entries(schema.fields)) {
|
|
54
|
+
const choices = literalChoices(member.ast);
|
|
55
|
+
if (choices !== undefined) {
|
|
56
|
+
offered.set(name, choices);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const valuesAtom = fromRef(values);
|
|
61
|
+
|
|
62
|
+
const decoded = runtime.atom((get) =>
|
|
63
|
+
decode(get(valuesAtom)).pipe(
|
|
64
|
+
Effect.match({
|
|
65
|
+
onFailure: (issue): Result.Result<Decoded<TFields>, SchemaIssue.Issue> => Result.fail(issue),
|
|
66
|
+
onSuccess: (value): Result.Result<Decoded<TFields>, SchemaIssue.Issue> => Result.succeed(value),
|
|
67
|
+
}),
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const schemaErrors = schemaFeedback(decoded);
|
|
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<TServices>(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(schemaErrors.field(name)) ?? get(checkError(name));
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const submitter: Submitter<Name<TFields>> = {
|
|
104
|
+
fail: (path, message) => Effect.fail(new FieldFailure(typeof path === "string" ? { message, path } : { message, path: path[0], segments: path })),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const submit = runtime.fn<void>()(() =>
|
|
108
|
+
Effect.gen(function* () {
|
|
109
|
+
status.attempt();
|
|
110
|
+
const submission = editing.submission();
|
|
111
|
+
const submitted = submission.values;
|
|
112
|
+
const value = yield* decode(submitted).pipe(Effect.mapError((issue) => new Invalid({ messages: messagesByField(issue) })));
|
|
113
|
+
return yield* onSubmit(value, submitter, submitted).pipe(
|
|
114
|
+
Effect.tapError(noted(submitted)),
|
|
115
|
+
Effect.tap(() => Effect.sync(submission.accept)),
|
|
116
|
+
);
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
blur: status.touch,
|
|
122
|
+
change: (name, value) => {
|
|
123
|
+
refFor(name).set(value);
|
|
124
|
+
status.touch(name);
|
|
125
|
+
},
|
|
126
|
+
choices: <TName extends Name<TFields>>(name: TName) => choicesOf<Encoded<TFields>[TName]>(offered.get(name)),
|
|
127
|
+
dirty: editing.dirty,
|
|
128
|
+
error,
|
|
129
|
+
errorAt: Atom.family((path: readonly [Name<TFields>, ...PropertyKey[]]) => {
|
|
130
|
+
const message = schemaErrors.at(path);
|
|
131
|
+
return Atom.readable((get) =>
|
|
132
|
+
get(status.touched)[path[0]] === true || get(status.submitted) ? (pathMessageAt(get(status.paths), path) ?? get(message)) : undefined,
|
|
133
|
+
);
|
|
134
|
+
}),
|
|
135
|
+
field: <TName extends Name<TFields>>(name: TName) => fieldOf<Encoded<TFields>[TName]>(refFor(name)),
|
|
136
|
+
receive: editing.receive,
|
|
137
|
+
revert: editing.revert,
|
|
138
|
+
submit,
|
|
139
|
+
submitting: Atom.map(submit, AsyncResult.isWaiting),
|
|
140
|
+
values,
|
|
141
|
+
};
|
|
142
|
+
}
|