@shivaedev/form 0.0.0-stage → 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 +315 -2
- 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 +81 -3
- 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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.6.2 - 2026-10-24
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- Require Effect and its companion packages at exactly 4.0.5. Generated applications use the same versions.
|
|
8
|
+
|
|
9
|
+
## 0.6.1 - 2026-10-24
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Align published dependency and peer pins and the generated starter with React and React DOM 19.3.1, TanStack Router 1.170.45, Better Auth 1.7.10, happy-dom 21.0.3 and Vitest 5.0.5. The starter also uses pg-boss dashboard 1.12.1.
|
|
14
|
+
|
|
15
|
+
## 0.6.0 - 2026-10-21
|
|
16
|
+
|
|
17
|
+
### Breaking
|
|
18
|
+
|
|
19
|
+
- Rename `make` to `form` in `@shivaedev/effect-form/form.ts`; change the named import and calls from `make` to `form`.
|
|
20
|
+
- Move `emptyAsNull` from `@shivaedev/effect-form/optional.ts` to `@shivaedev/effect-form/emptyAsNull.ts`; change the import path and keep the `emptyAsNull` name.
|
|
21
|
+
- Move `statusOf` from `@shivaedev/effect-form/status.ts` to `@shivaedev/effect-form/statusOf.ts`; change the import path and keep the `statusOf` name.
|
|
22
|
+
|
|
23
|
+
## 0.5.0 - 2026-10-18
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- Keep form and React hook type inference consistent when TypeScript enables the `source` condition by resolving private type aliases to the same declarations as public imports.
|
|
28
|
+
|
|
29
|
+
### Breaking
|
|
30
|
+
|
|
31
|
+
- Every module's import path is now its path under `src/`. The React entry moves from `react.ts` to `client/react.ts`.
|
|
32
|
+
|
|
33
|
+
## 0.4.0 - 2026-10-13
|
|
34
|
+
|
|
35
|
+
### Breaking
|
|
36
|
+
|
|
37
|
+
- Requires stable Effect 4.0.4 in place of 4.0.0-rc.112. Effect 4.0.4 moves the former `effect/unstable/*` modules to `effect/*` and renames constructors such as `Config.string` to `Config.String`. Install `@effect/atom-react` 4.0.4 alongside it.
|
|
38
|
+
|
|
39
|
+
## 0.3.0 - 2026-10-13
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- Opt-in `Config.persistedDraft` stores encoded input and its baseline under an application key, bounded by age and size, while storage failures leave editing usable. Reload and refresh preserve edits, revert and successful submission clear accepted input, and later edits remain persisted.
|
|
44
|
+
- `persistence.ts` exports `PersistedDraftConfig`, `DraftStorage`, `Persistence` and `persistence`; `draftSession.ts` exports `DraftSession` and `draftSession`. React exports `usePersistedDraft`, `BrowserDraftConfig` and `DraftSessionContext` to bind browser storage and session ownership.
|
|
45
|
+
|
|
46
|
+
## 0.2.1 - 2026-10-12
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- `submitter.fail` accepts nested path tuples, and `errorAt` shows typed handler rejections beside Schema messages for individual array elements.
|
|
51
|
+
- Optional typed `arrayKeys` selectors keep element rejections with their keys through insertion, removal and reorder, including late save responses. Changed or removed inputs discard their rejection; arrays without selectors use index paths.
|
|
52
|
+
- `Form.errorAt(path)` reads nested Schema feedback beneath a top-level field, including individual array entries, without adding nested field state. Top-level touch/submission controls visibility; `error(name)` retains aggregated Schema, handler and advisory messages. `messageAtPath(issue, path)` selects the first Schema message at or beneath a path; `schemaFeedback(decoded)` adapts a decoded result atom to aggregated and nested messages.
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- Document the package's purpose, form model, examples, API and boundaries; publish the README and `docs/` while keeping agent guidance out of the package.
|
|
57
|
+
- Build package JavaScript, declarations and source maps with TypeScript 7.
|
|
58
|
+
|
|
59
|
+
## 0.2.0 - 2026-10-04
|
|
60
|
+
|
|
61
|
+
### Changed
|
|
62
|
+
|
|
63
|
+
- Breaking: no root entry and no module that re-exports another. `exports` maps one `"./*.ts"` pattern to every module (`src` under the `source` condition, `dist` otherwise), so you import the module that defines a name, and a bundler sees only the modules you use. Where each name now lives:
|
|
64
|
+
- `@shivaedev/effect-form`: `@shivaedev/effect-form/form.ts` (`make`), `@shivaedev/effect-form/messages.ts` (`FieldMessages`), `@shivaedev/effect-form/optional.ts` (`emptyAsNull`), `@shivaedev/effect-form/shape.ts` (`Checks`, `Config`, `Decoded`, `Encoded`, `Fields`, `Form`, `Name`, `Services`, `Submitter`, `FieldFailure`, `Invalid`)
|
|
65
|
+
- `@shivaedev/effect-form/react`: `@shivaedev/effect-form/react.ts`
|
|
66
|
+
|
|
67
|
+
## 0.1.2 - 2026-10-04
|
|
68
|
+
|
|
69
|
+
### Changed
|
|
70
|
+
|
|
71
|
+
- Imports that leave their folder go through `#` aliases declared in the `imports` field of `package.json`: `#*.ts` resolves to `src` under the `source` condition and to `dist` otherwise, and `#test/*` names a test file. The published modules and declarations name the package's own files through these aliases.
|
|
72
|
+
|
|
73
|
+
- Consolidate duplicate test cases around observable package behavior.
|
|
74
|
+
|
|
75
|
+
- Validate packed consumers and executable bins through the shared workspace gate.
|
|
76
|
+
|
|
77
|
+
- Build with the `@shivaedev/quality` tsconfig presets, which target ESNext and allow only erasable TypeScript syntax.
|
|
78
|
+
|
|
79
|
+
- Type-check with TypeScript 7 only. TypeScript 6 still builds the package.
|
|
80
|
+
|
|
81
|
+
- Lint and format with the `@shivaedev/quality` Biome preset through `quality lint` at the repository root, which replaces the package's `check` script.
|
|
82
|
+
|
|
83
|
+
- Run tests through the `@shivaedev/quality` Vitest projects, which take a test's environment from its file name. Type tests are named `*.typecheck.test.ts`.
|
|
84
|
+
|
|
85
|
+
- Keep `package.json` in sort-package-json key order, checked by `quality lint`.
|
|
86
|
+
|
|
87
|
+
## 0.1.1 - 2026-09-26
|
|
88
|
+
|
|
89
|
+
### Changed
|
|
90
|
+
|
|
91
|
+
- Pin the `react` peer dependency to exactly 19.2.8, the version the package is
|
|
92
|
+
tested against, instead of `^19.2.8`.
|
|
93
|
+
|
|
94
|
+
## 0.1.0 - 2026-09-26
|
|
95
|
+
|
|
96
|
+
### Added
|
|
97
|
+
|
|
98
|
+
- Extract schema-derived form state, submission, field messages, draft
|
|
99
|
+
preservation, and optional React hooks into a reusable package.
|
|
100
|
+
- Merge received values per field: untouched fields adopt refreshed server
|
|
101
|
+
values while edited fields keep local input.
|
|
102
|
+
- Keep a refresh received during an in-flight save as the baseline instead of
|
|
103
|
+
replacing it with the older submitted values.
|
|
104
|
+
- Show an async check message only while the field still holds the value it was
|
|
105
|
+
computed for.
|
|
106
|
+
- Clear optional field values and notify subscribers when a received or reverted
|
|
107
|
+
baseline omits the field.
|
|
108
|
+
- Allow submit handlers to use the native atom runtime's scope, registry, and
|
|
109
|
+
reactivity services.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ShivaeDev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,316 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @shivaedev/form
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Keep input values, decoded submissions and fresh server data in one form. Define fields with Effect Schema, submit domain values, and preserve local edits when a refresh or save finishes.
|
|
4
|
+
|
|
5
|
+
## Why you want this
|
|
6
|
+
|
|
7
|
+
An input gives you a string; your service needs a number. Meanwhile, fresh server data can arrive beside an unsaved edit. Repeating those conversions and merge rules in components makes every form another state machine. Here, the schema owns the conversion and the form owns the editing session:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
const order = form(Schema.Struct({ name: Schema.String, quantity: Schema.NumberFromString }), {
|
|
11
|
+
initialValues: { name: "Paper", quantity: "300" },
|
|
12
|
+
onSubmit: Effect.succeed,
|
|
13
|
+
runtime,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
order.change("name", "Copy paper");
|
|
17
|
+
order.receive({ name: "Paper", quantity: "350" });
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The name stays `"Copy paper"`; the untouched quantity becomes `"350"`. Submitting gives the handler quantity `350`, a number. The same form can be used through native atoms or small React hooks, so the component does not need another draft model.
|
|
21
|
+
|
|
22
|
+
## Using it
|
|
23
|
+
|
|
24
|
+
### How to think about it
|
|
25
|
+
|
|
26
|
+
A form is one **editing session** over a `Schema.Struct`. Its fields hold the schema's **encoded values**, the values an input edits. Its handler receives **decoded values**, the domain values produced by Schema. For `Schema.NumberFromString`, that means a field holds `"350"` and the handler receives `350`.
|
|
27
|
+
|
|
28
|
+
The **baseline** is the encoded value against which the form recognizes edits. It starts with `initialValues`, moves when the form receives server values, and normally moves to a successful submission's encoded snapshot. A **submission** captures the encoded values before decoding and saving. Editing can continue afterwards; those later edits are separate from that submission.
|
|
29
|
+
|
|
30
|
+
Keep one form instance for one editing session. Use a new instance for a different entity or user session. Query acquisition, entity identity, normalized saved rows and create-reset orchestration can be composed with [`useEditor` and `useCreate`](https://github.com/ShivaeDev/platform/blob/main/packages/react/README.md) from `@shivaedev/react`.
|
|
31
|
+
|
|
32
|
+
### Once per application: supply the runtime and registry
|
|
33
|
+
|
|
34
|
+
`form` takes an atom runtime. Build its Layer from the services your submit handler and field checks need. A handler that reads a service outside that runtime's declared dependencies does not compile. The handler's type also permits the native `Scope`, `AtomRegistry` and `Reactivity` services.
|
|
35
|
+
|
|
36
|
+
Use an `AtomRegistry` to mount and read atoms and write the submit atom. Own its lifetime at the application boundary. In React, provide that registry through `RegistryContext` from `@effect/atom-react`.
|
|
37
|
+
|
|
38
|
+
### Once per feature: define fields and submission
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { Effect, Layer, Schema } from "effect";
|
|
42
|
+
import * as Atom from "effect/reactivity/Atom";
|
|
43
|
+
import * as AtomRegistry from "effect/reactivity/AtomRegistry";
|
|
44
|
+
import { form } from "@shivaedev/form/form.ts";
|
|
45
|
+
|
|
46
|
+
const runtime = Atom.runtime(Layer.empty);
|
|
47
|
+
const fields = Schema.Struct({
|
|
48
|
+
name: Schema.NonEmptyString,
|
|
49
|
+
quantity: Schema.NumberFromString,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const order = form(fields, {
|
|
53
|
+
initialValues: { name: "Paper", quantity: "300" },
|
|
54
|
+
onSubmit: (values, submitter) =>
|
|
55
|
+
values.name === "reserved"
|
|
56
|
+
? submitter.fail("name", "Choose another name")
|
|
57
|
+
: Effect.succeed(values),
|
|
58
|
+
runtime,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const registry = AtomRegistry.make();
|
|
62
|
+
const release = registry.mount(order.dirty);
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
order.change("quantity", "350");
|
|
66
|
+
registry.set(order.submit, undefined);
|
|
67
|
+
const saved = await Effect.runPromise(
|
|
68
|
+
AtomRegistry.getResult(registry, order.submit, { suspendOnWaiting: true }),
|
|
69
|
+
);
|
|
70
|
+
console.log(saved);
|
|
71
|
+
} finally {
|
|
72
|
+
release();
|
|
73
|
+
registry.dispose();
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The input quantity is a string; the successful result contains quantity `350`. Invalid input fails with `Invalid` before `onSubmit` runs. The handler can fail a named field with `submitter.fail`; changing that field clears the rejection, and another submission uses the new input.
|
|
78
|
+
|
|
79
|
+
The handler has three arguments: decoded values, the submitter, and the submitted encoded snapshot. Use the third argument when coordinating work against what was submitted, rather than reading live fields after an asynchronous decode. The `react` create adapter uses that snapshot to preserve input typed while decoding.
|
|
80
|
+
|
|
81
|
+
### In the UI: bind the same form
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
import { RegistryContext } from "@effect/atom-react";
|
|
85
|
+
import { Effect, Layer, Schema } from "effect";
|
|
86
|
+
import * as Atom from "effect/reactivity/Atom";
|
|
87
|
+
import * as AtomRegistry from "effect/reactivity/AtomRegistry";
|
|
88
|
+
import { useState } from "react";
|
|
89
|
+
import { createRoot } from "react-dom/client";
|
|
90
|
+
import { form } from "@shivaedev/form/form.ts";
|
|
91
|
+
import { useDirty, useField, useSubmit } from "@shivaedev/form/client/react.ts";
|
|
92
|
+
|
|
93
|
+
const runtime = Atom.runtime(Layer.empty);
|
|
94
|
+
const fields = Schema.Struct({ name: Schema.NonEmptyString, quantity: Schema.NumberFromString });
|
|
95
|
+
|
|
96
|
+
function OrderEditor() {
|
|
97
|
+
const [order] = useState(() => form(fields, {
|
|
98
|
+
initialValues: { name: "Paper", quantity: "300" },
|
|
99
|
+
onSubmit: Effect.succeed,
|
|
100
|
+
runtime,
|
|
101
|
+
}));
|
|
102
|
+
const name = useField(order, "name");
|
|
103
|
+
const quantity = useField(order, "quantity");
|
|
104
|
+
const submit = useSubmit(order);
|
|
105
|
+
const dirty = useDirty(order);
|
|
106
|
+
|
|
107
|
+
return <form onSubmit={(event) => {
|
|
108
|
+
event.preventDefault();
|
|
109
|
+
if (!submit.submitting) submit.run();
|
|
110
|
+
}}>
|
|
111
|
+
<label>Name <input value={name.value} onBlur={name.onBlur}
|
|
112
|
+
onChange={(event) => name.onChange(event.target.value)} /></label>
|
|
113
|
+
{name.error && <p role="alert">{name.error}</p>}
|
|
114
|
+
<label>Quantity <input value={quantity.value} onBlur={quantity.onBlur}
|
|
115
|
+
onChange={(event) => quantity.onChange(event.target.value)} /></label>
|
|
116
|
+
{quantity.error && <p role="alert">{quantity.error}</p>}
|
|
117
|
+
<button disabled={submit.submitting}>Save</button>
|
|
118
|
+
<button type="button" onClick={order.revert}>Revert</button>
|
|
119
|
+
<p role="status">{submit.submitting ? "Saving…" : dirty ? "Unsaved changes" : "Saved"}</p>
|
|
120
|
+
</form>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function mountOrderEditor(container: HTMLElement) {
|
|
124
|
+
const registry = AtomRegistry.make();
|
|
125
|
+
const root = createRoot(container);
|
|
126
|
+
root.render(<RegistryContext.Provider value={registry}><OrderEditor /></RegistryContext.Provider>);
|
|
127
|
+
return () => {
|
|
128
|
+
root.unmount();
|
|
129
|
+
registry.dispose();
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`useState` creates the form once for this component's editing session. `useField` supplies the field's name, encoded value, `error`, `choices` and change/blur callbacks; `useSubmit` supplies the native result, a submit callback and waiting state; `useDirty` reads whether edits remain. The mount function supplies a registry and returns its cleanup.
|
|
135
|
+
|
|
136
|
+
Render general submission failures from `submit.result` in the application's own words. A field rejection and schema failure are available there too; showing a field message does not replace handling the whole result.
|
|
137
|
+
|
|
138
|
+
### Receive server values without losing edits
|
|
139
|
+
|
|
140
|
+
Call `receive` with encoded values from a refreshed or saved row. Encode domain values before receiving them; for a numeric string field, receive `String(row.quantity)`.
|
|
141
|
+
|
|
142
|
+
| Situation | What the form does |
|
|
143
|
+
| --- | --- |
|
|
144
|
+
| A field still equals its baseline | Receives the new value. |
|
|
145
|
+
| A field was edited locally | Keeps that input; received values become the new baseline. |
|
|
146
|
+
| A refresh omits an untouched optional key | Removes the key. |
|
|
147
|
+
| An optional key was added locally | Keeps it when a refresh omits it; revert restores the received values. |
|
|
148
|
+
| The handler succeeds | Accepts the submitted encoded values without replacing later edits. |
|
|
149
|
+
| A refresh arrived during a successful save | Keeps that refresh as the baseline; values still equal to the accepted submission count as saved and adopt the next refresh. |
|
|
150
|
+
| The handler fails | Keeps input; an intervening refresh remains the baseline. |
|
|
151
|
+
| `revert()` is called | Restores the latest baseline, including omitted optional keys. |
|
|
152
|
+
|
|
153
|
+
The form does not replace submitted encoded input with its decoded representation. A trimmed name can be accepted as saved while the input still contains its spaces. Receive the normalized saved row when the application wants that representation on screen. `useEditor` from `react` performs that composition.
|
|
154
|
+
|
|
155
|
+
### Persist input across reloads
|
|
156
|
+
|
|
157
|
+
Opt in with `persistedDraft`. The application chooses a key for one logical editing session. Keep that key stable across server revisions so a refresh can find the same draft; choose a new key when the encoded field shape changes. Stored input includes its baseline and edited field names, so edited fields survive reload while untouched fields adopt the latest received values.
|
|
158
|
+
|
|
159
|
+
For a core form, supply the storage provider explicitly:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
const order = form(fields, {
|
|
163
|
+
initialValues: { name: "Paper", quantity: "300" },
|
|
164
|
+
onSubmit: Effect.succeed,
|
|
165
|
+
persistedDraft: { key: "order:123", storage: () => window.localStorage },
|
|
166
|
+
runtime,
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
For a React form, call `usePersistedDraft` before creating the form. It defaults to browser `localStorage` and attaches the current `SessionBoundary` draft owner:
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
import { useState } from "react";
|
|
174
|
+
import { usePersistedDraft } from "@shivaedev/form/client/react.ts";
|
|
175
|
+
|
|
176
|
+
function OrderEditor() {
|
|
177
|
+
const persistedDraft = usePersistedDraft({ key: "order:123" });
|
|
178
|
+
const [order] = useState(() => form(fields, {
|
|
179
|
+
initialValues: { name: "Paper", quantity: "300" },
|
|
180
|
+
onSubmit: Effect.succeed,
|
|
181
|
+
persistedDraft,
|
|
182
|
+
runtime,
|
|
183
|
+
}));
|
|
184
|
+
return <OrderFields form={form} />;
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
`OrderFields` is the application's field UI. Keep this component mounted for one record; remount it when its identity changes. `useEditor` and `useCreate` from `react` accept `persistedDraft: { key }` directly and attach the same owner. A committed `SessionBoundary` session replacement or sign-out drops its tracked drafts, including screens already unmounted, and disables writes from its old forms. A suspended replacement that is canceled preserves the visible session's stored draft and future persistence. Ordinary unmounts and React effect replay preserve stored drafts. A stored draft with a different session generation is discarded when read.
|
|
189
|
+
|
|
190
|
+
Each stored record defaults to at most 256 KiB of UTF-8 JSON and seven days since its last write. Set `maxBytes` and `maxAgeMs` to finite positive limits to change those bounds. Oversized, expired, malformed or incompatible records are dropped when encountered. Persistence requires JSON-compatible encoded input; invalid input such as an empty required string or an unfinished numeric string still persists. Storage access, serialization and quota failures leave the form usable.
|
|
191
|
+
|
|
192
|
+
Edits and received values update the stored draft. Revert removes it. A successful submit clears accepted input; edits made after the submitted snapshot remain persisted. A failed submit keeps the draft.
|
|
193
|
+
|
|
194
|
+
Outside React, pass a `session` from `draftSession(id)` in `@shivaedev/form/draftSession.ts` and call its `drop()` when the application replaces that session. Core forms without a session owner use only the application's key. `DraftSessionContext` supplies the owner to React integrations.
|
|
195
|
+
|
|
196
|
+
### Field feedback and advisory checks
|
|
197
|
+
|
|
198
|
+
An untouched invalid field initially has no visible message. Invalid submission reveals schema messages, and editing a field updates its feedback. A server rejection clears on an edit; a rejection that arrives after the field has changed does not attach to the new value.
|
|
199
|
+
|
|
200
|
+
For `Schema.NonEmptyString`, an empty input's message is `Required`. Longer minimum-length checks retain Schema's message.
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
const profile = form(Schema.Struct({ name: Schema.String }), {
|
|
204
|
+
checks: {
|
|
205
|
+
name: (value) => Effect.succeed(value === "taken" ? "Name is taken" : undefined),
|
|
206
|
+
},
|
|
207
|
+
debounce: "300 millis",
|
|
208
|
+
initialValues: { name: "" },
|
|
209
|
+
onSubmit: Effect.succeed,
|
|
210
|
+
runtime,
|
|
211
|
+
});
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
This check produces advisory feedback after the configured debounce and completion. A message is shown only while its checked input is still the current field value. Pending checks, older results and a defective check do not show an earlier value's message. Checks can read services from the supplied runtime. Keep authoritative validation in the schema or handler.
|
|
215
|
+
|
|
216
|
+
For a nested value held in one top-level field, `errorAt(["answers", index, "text"])` reads Schema feedback and handler rejections for that answer box. A blur or change of the top-level `answers` field, or a submission, reveals its nested messages. The path selects the first message at or beneath that path; a sibling message does not appear there. `error("answers")` retains the top-level summary. Advisory checks remain top-level. Nested values are still edited as a whole top-level field.
|
|
217
|
+
|
|
218
|
+
### Errors on array elements
|
|
219
|
+
|
|
220
|
+
Pass a path tuple to `submitter.fail` to reject one element or a property inside it. Paths use indices in the submitted encoded snapshot. Optional `arrayKeys` selectors receive encoded elements of top-level array fields and return their stable, unique keys:
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
const answers = form(Schema.Struct({
|
|
224
|
+
answers: Schema.Array(Schema.Struct({ id: Schema.String, text: Schema.NonEmptyString })),
|
|
225
|
+
}), {
|
|
226
|
+
arrayKeys: { answers: answer => answer.id },
|
|
227
|
+
initialValues: { answers: [{ id: "question", text: "Yes" }] },
|
|
228
|
+
onSubmit: (_values, submitter) =>
|
|
229
|
+
submitter.fail(["answers", 0, "text"], "This answer is closed"),
|
|
230
|
+
runtime,
|
|
231
|
+
});
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`FieldFailure.path` keeps the top-level field name as a string. For a tuple rejection, optional `FieldFailure.segments` contains its submitted path.
|
|
235
|
+
|
|
236
|
+
Read the message at the element's **current** index: in React, `useAtomValue(answers.errorAt(["answers", index, "text"]))` from `@effect/atom-react` renders it beside that answer. Schema messages update from current values. Keyed handler rejections follow insertion, removal and reorder, even when elements are recreated, and a late rejection follows its submitted key to the current index. Editing the rejected input or removing its element discards the rejection; editing a sibling or blurring the array preserves it. Duplicate keys cannot identify an element and discard its rejection.
|
|
237
|
+
|
|
238
|
+
Without a selector for an array, handler errors fall back to index paths. They remain at that index while its input value matches, even if a different element with the same value moves there. Arrays nested inside an element also use index paths. Use immutable value updates and stable, unique keys for top-level arrays whose errors must follow elements.
|
|
239
|
+
|
|
240
|
+
### Choices and nullable inputs
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
import { Schema } from "effect";
|
|
244
|
+
import { emptyAsNull } from "@shivaedev/form/emptyAsNull.ts";
|
|
245
|
+
|
|
246
|
+
const fields = Schema.Struct({
|
|
247
|
+
visibility: Schema.Literals(["private", "public"]),
|
|
248
|
+
quantity: emptyAsNull(Schema.NumberFromString),
|
|
249
|
+
});
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
For a form made with these fields, `order.choices("visibility")` returns the encoded literals `"private"` and `"public"`. Choices also stay encoded when the field's codec transforms those literals. `emptyAsNull` decodes `""` to `null` and uses the inner codec for populated input; encoding `null` gives `""`.
|
|
253
|
+
|
|
254
|
+
### API
|
|
255
|
+
|
|
256
|
+
Import the module that defines a name; there is no root entry.
|
|
257
|
+
|
|
258
|
+
| Module | Application-facing exports |
|
|
259
|
+
| --- | --- |
|
|
260
|
+
| `@shivaedev/form/form.ts` | `form(schema, config)` |
|
|
261
|
+
| `@shivaedev/form/shape.ts` | `Form`, `Submitter`, `FieldFailure`, `Invalid` and the field types they use |
|
|
262
|
+
| `@shivaedev/form/client/react.ts` | `useField`, `useSubmit`, `useDirty`, `usePersistedDraft`, `DraftSessionContext` |
|
|
263
|
+
| `@shivaedev/form/draftSession.ts` | `draftSession(id)` |
|
|
264
|
+
| `@shivaedev/form/emptyAsNull.ts` | `emptyAsNull` |
|
|
265
|
+
|
|
266
|
+
`form` takes a `Schema.Struct` and this configuration:
|
|
267
|
+
|
|
268
|
+
| Option | Type or role |
|
|
269
|
+
| --- | --- |
|
|
270
|
+
| `initialValues` | `Encoded<F>` |
|
|
271
|
+
| `runtime` | Atom runtime for the handler/check services and schema decoding services |
|
|
272
|
+
| `onSubmit` | `(values, submitter, submitted) => Effect<A, E, R>`; the three arguments are decoded values, field failure helper and encoded snapshot |
|
|
273
|
+
| `checks` | Optional functions keyed by field, from encoded input to an Effect of `string \| undefined` |
|
|
274
|
+
| `arrayKeys` | Optional `ArrayKeys<F>` selectors keyed by top-level array field, from encoded element to stable `PropertyKey`; retains nested feedback across reordering |
|
|
275
|
+
| `debounce` | Optional Effect `Duration.Input` for checks |
|
|
276
|
+
| `persistedDraft` | Optional `{ key, storage, maxBytes?, maxAgeMs?, session? }`; React's `usePersistedDraft` supplies storage and session ownership |
|
|
277
|
+
|
|
278
|
+
The returned form exposes:
|
|
279
|
+
|
|
280
|
+
| Member | Type or use |
|
|
281
|
+
| --- | --- |
|
|
282
|
+
| `values` | `AtomRef<Encoded<F>>` for the whole input |
|
|
283
|
+
| `field(name)` | `AtomRef<Encoded<F>[K]>` for a field |
|
|
284
|
+
| `change(name, value)` | Write encoded input through the form's editing operation |
|
|
285
|
+
| `blur(name)` | Field blur operation, supplied as `useField(...).onBlur` |
|
|
286
|
+
| `error(name)` | Atom of `string \| undefined` |
|
|
287
|
+
| `errorAt([name, ...path])` | Atom of nested Schema feedback and handler rejections; visibility follows the top-level field |
|
|
288
|
+
| `choices(name)` | Encoded choices or `undefined` |
|
|
289
|
+
| `dirty` | Atom of `boolean` |
|
|
290
|
+
| `receive(values)` | Merge received encoded values |
|
|
291
|
+
| `revert()` | Restore the baseline |
|
|
292
|
+
| `submit` | `AtomResultFn<void, A, E \| ER \| FieldFailure \| Invalid>` |
|
|
293
|
+
| `submitting` | Atom of `boolean` for submission waiting state |
|
|
294
|
+
|
|
295
|
+
`F` is the schema's fields, `A` and `E` are the handler's success and failure types, and `ER` is the atom runtime's failure type. `FieldFailure` contains the top-level string `path`, `message` and optional submitted path `segments`; `Invalid` contains `messages`, a `FieldMessages` record.
|
|
296
|
+
|
|
297
|
+
The lower-level `draft.ts` (`draft`), `refs.ts` (`fromRef`, `propertyRef`) and `statusOf.ts` (`statusOf`) modules hold the machinery the form composes; the examples use the form API instead.
|
|
298
|
+
|
|
299
|
+
### Install and limits
|
|
300
|
+
|
|
301
|
+
```sh
|
|
302
|
+
pnpm add @shivaedev/form effect@4.0.5
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
For React bindings:
|
|
306
|
+
|
|
307
|
+
```sh
|
|
308
|
+
pnpm add @effect/atom-react@4.0.5 react@19.3.0
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
The React mounting example also uses the application's `react-dom`. React and `@effect/atom-react` are optional peers; the core modules do not import them. The package needs Node 24 or newer.
|
|
312
|
+
|
|
313
|
+
- Field names are top-level schema keys. There is no path-addressed field-array API.
|
|
314
|
+
- Replace encoded values rather than mutating objects in place; use `change` for ordinary UI edits.
|
|
315
|
+
- Keep the form and registry for their intended session, and clean up the registry when that session ends.
|
|
316
|
+
- Decide in the application when to receive normalized rows and how to render general failures. Attach a draft owner when persisted input must end with the session.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type * as AsyncResult from "effect/reactivity/AsyncResult";
|
|
2
|
+
import type { DraftSession } from "#draftSession.ts";
|
|
3
|
+
import type { DraftStorage, PersistedDraftConfig } from "#persistence.ts";
|
|
4
|
+
import type { Encoded, FieldFailure, Fields, Form, Invalid, Name } from "#shape.ts";
|
|
5
|
+
export declare const DraftSessionContext: import("react").Context<DraftSession | undefined>;
|
|
6
|
+
export interface BrowserDraftConfig extends Omit<PersistedDraftConfig, "storage"> {
|
|
7
|
+
readonly storage?: () => DraftStorage;
|
|
8
|
+
}
|
|
9
|
+
export declare function usePersistedDraft(config: BrowserDraftConfig): PersistedDraftConfig;
|
|
10
|
+
export declare function usePersistedDraft(config: BrowserDraftConfig | undefined): PersistedDraftConfig | undefined;
|
|
11
|
+
export interface Field<TFields extends Fields, TKey extends Name<TFields>> {
|
|
12
|
+
readonly choices: readonly Encoded<TFields>[TKey][] | undefined;
|
|
13
|
+
readonly error: string | undefined;
|
|
14
|
+
readonly name: TKey;
|
|
15
|
+
readonly onBlur: () => void;
|
|
16
|
+
readonly onChange: (value: Encoded<TFields>[TKey]) => void;
|
|
17
|
+
readonly value: Encoded<TFields>[TKey];
|
|
18
|
+
}
|
|
19
|
+
export declare function useField<TFields extends Fields, A, E, TRuntimeError, TKey extends Name<TFields>>(form: Form<TFields, A, E, TRuntimeError>, name: TKey): Field<TFields, TKey>;
|
|
20
|
+
export interface Submit<A, E> {
|
|
21
|
+
readonly result: AsyncResult.AsyncResult<A, E>;
|
|
22
|
+
readonly run: () => void;
|
|
23
|
+
readonly submitting: boolean;
|
|
24
|
+
}
|
|
25
|
+
export declare function useSubmit<TFields extends Fields, A, E, TRuntimeError>(form: Form<TFields, A, E, TRuntimeError>): Submit<A, E | TRuntimeError | FieldFailure | Invalid>;
|
|
26
|
+
export declare function useDirty<TFields extends Fields, A, E, TRuntimeError>(form: Form<TFields, A, E, TRuntimeError>): boolean;
|
|
27
|
+
//# sourceMappingURL=react.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
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"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useAtomRef, useAtomSet, useAtomValue } from "@effect/atom-react";
|
|
2
|
+
import { createContext, useCallback, useContext, useMemo } from "react";
|
|
3
|
+
export const DraftSessionContext = createContext(undefined);
|
|
4
|
+
function browserStorage() {
|
|
5
|
+
return globalThis.localStorage;
|
|
6
|
+
}
|
|
7
|
+
export function usePersistedDraft(config) {
|
|
8
|
+
const session = useContext(DraftSessionContext);
|
|
9
|
+
return config ? { ...config, session: session ?? config.session, storage: config.storage ?? browserStorage } : undefined;
|
|
10
|
+
}
|
|
11
|
+
export function useField(form, name) {
|
|
12
|
+
const value = useAtomRef(form.field(name));
|
|
13
|
+
const error = useAtomValue(form.error(name));
|
|
14
|
+
const onChange = useCallback((next) => form.change(name, next), [form, name]);
|
|
15
|
+
const onBlur = useCallback(() => form.blur(name), [form, name]);
|
|
16
|
+
const choices = useMemo(() => form.choices(name), [form, name]);
|
|
17
|
+
return { choices, error, name, onBlur, onChange, value };
|
|
18
|
+
}
|
|
19
|
+
export function useSubmit(form) {
|
|
20
|
+
const result = useAtomValue(form.submit);
|
|
21
|
+
const set = useAtomSet(form.submit);
|
|
22
|
+
const run = useCallback(() => set(), [set]);
|
|
23
|
+
return { result, run, submitting: result.waiting };
|
|
24
|
+
}
|
|
25
|
+
export function useDirty(form) {
|
|
26
|
+
return useAtomValue(form.dirty);
|
|
27
|
+
}
|
|
28
|
+
//# sourceMappingURL=react.js.map
|
|
@@ -0,0 +1 @@
|
|
|
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"}
|
package/dist/draft.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as Atom from "effect/reactivity/Atom";
|
|
2
|
+
import * as AtomRef from "effect/reactivity/AtomRef";
|
|
3
|
+
import type { Persistence } from "./persistence.ts";
|
|
4
|
+
type Entries = Readonly<Record<string, unknown>>;
|
|
5
|
+
export declare function draft<TValues extends Entries>(initial: TValues, persistence?: Persistence<TValues>): {
|
|
6
|
+
dirty: Atom.Atom<boolean>;
|
|
7
|
+
receive: (incoming: TValues) => void;
|
|
8
|
+
revert: () => void;
|
|
9
|
+
submission: () => {
|
|
10
|
+
accept: () => void;
|
|
11
|
+
values: TValues;
|
|
12
|
+
};
|
|
13
|
+
values: AtomRef.AtomRef<TValues>;
|
|
14
|
+
};
|
|
15
|
+
export {};
|
|
16
|
+
//# sourceMappingURL=draft.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"draft.d.ts","sourceRoot":"","sources":["../src/draft.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,IAAI,MAAM,wBAAwB,CAAC;AAC/C,OAAO,KAAK,OAAO,MAAM,2BAA2B,CAAC;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEpD,KAAK,OAAO,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAsCjD,wBAAgB,KAAK,CAAC,OAAO,SAAS,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,WAAW,CAAC,EAAE,WAAW,CAAC,OAAO,CAAC;IAuBjG,KAAK;IAIL,OAAO,aAAa,OAAO,KAAG,IAAI;IAOlC,MAAM,QAAM,IAAI;IAOhB,UAAU;QAGR,MAAM,QAAM,IAAI;QAKhB,MAAM;;IAGR,MAAM;EAEP"}
|
package/dist/draft.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Equal } from "effect";
|
|
2
|
+
import * as Atom from "effect/reactivity/Atom";
|
|
3
|
+
import * as AtomRef from "effect/reactivity/AtomRef";
|
|
4
|
+
const none = {};
|
|
5
|
+
function untouched({ baseline, current, settled }, name) {
|
|
6
|
+
return Equal.equals(current[name], baseline[name]) || (Object.hasOwn(settled, name) && Equal.equals(current[name], settled[name]));
|
|
7
|
+
}
|
|
8
|
+
function changed(held) {
|
|
9
|
+
return Object.keys({ ...held.baseline, ...held.current }).some((name) => !untouched(held, name));
|
|
10
|
+
}
|
|
11
|
+
function merged(held, incoming) {
|
|
12
|
+
const result = {};
|
|
13
|
+
for (const name of Object.keys({ ...held.current, ...incoming })) {
|
|
14
|
+
const source = untouched(held, name) ? incoming : held.current;
|
|
15
|
+
if (Object.hasOwn(source, name)) {
|
|
16
|
+
result[name] = source[name];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
function matching(current, submitted) {
|
|
22
|
+
return Object.fromEntries(Object.keys({ ...current, ...submitted })
|
|
23
|
+
.filter((name) => Equal.equals(current[name], submitted[name]))
|
|
24
|
+
.map((name) => [name, submitted[name]]));
|
|
25
|
+
}
|
|
26
|
+
export function draft(initial, persistence) {
|
|
27
|
+
const restored = persistence?.read();
|
|
28
|
+
const loaded = {
|
|
29
|
+
baseline: restored?.baseline ?? initial,
|
|
30
|
+
current: restored?.values ?? initial,
|
|
31
|
+
settled: restored ? Object.fromEntries(Object.entries(restored.values).filter(([name]) => !restored.edited.includes(name))) : none,
|
|
32
|
+
};
|
|
33
|
+
const state = AtomRef.make({
|
|
34
|
+
baseline: initial,
|
|
35
|
+
current: restored ? merged(loaded, initial) : initial,
|
|
36
|
+
settled: none,
|
|
37
|
+
});
|
|
38
|
+
if (persistence) {
|
|
39
|
+
state.subscribe((value) => {
|
|
40
|
+
if (changed(value)) {
|
|
41
|
+
const edited = Object.keys({ ...value.baseline, ...value.current }).filter((name) => !untouched(value, name));
|
|
42
|
+
persistence.write({ ...value.baseline, ...value.settled }, value.current, edited);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
persistence.clear();
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
dirty: Atom.readable((get) => {
|
|
51
|
+
get.addFinalizer(state.subscribe((value) => get.setSelf(changed(value))));
|
|
52
|
+
return changed(state.value);
|
|
53
|
+
}),
|
|
54
|
+
receive: (incoming) => {
|
|
55
|
+
state.update((held) => ({
|
|
56
|
+
baseline: incoming,
|
|
57
|
+
current: changed(held) ? merged(held, incoming) : incoming,
|
|
58
|
+
settled: none,
|
|
59
|
+
}));
|
|
60
|
+
},
|
|
61
|
+
revert: () => {
|
|
62
|
+
state.update((held) => ({
|
|
63
|
+
...held,
|
|
64
|
+
current: held.baseline,
|
|
65
|
+
settled: none,
|
|
66
|
+
}));
|
|
67
|
+
},
|
|
68
|
+
submission: () => {
|
|
69
|
+
const { baseline, current: submitted } = state.value;
|
|
70
|
+
return {
|
|
71
|
+
accept: () => {
|
|
72
|
+
state.update((held) => held.baseline === baseline ? { ...held, baseline: submitted, settled: none } : { ...held, settled: matching(held.current, submitted) });
|
|
73
|
+
},
|
|
74
|
+
values: submitted,
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
values: state.prop("current"),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
//# sourceMappingURL=draft.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"draft.js","sourceRoot":"","sources":["../src/draft.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
export declare function draftSession(id: string): DraftSession;
|
|
8
|
+
//# sourceMappingURL=draftSession.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"draftSession.d.ts","sourceRoot":"","sources":["../src/draftSession.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,YAAY;IAC5B,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,IAAI,CAAC;IAC1B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,IAAI,KAAK,IAAI,CAAC;CACzD;AAED,wBAAgB,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,YAAY,CAqBrD"}
|