@playfast/reform-forms 1.1.1 → 1.2.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/README.md +3 -5
- package/package.json +19 -19
- package/src/form.test.ts +37 -52
- package/src/form.ts +63 -118
- package/src/formMake.ts +95 -0
- package/src/formState.ts +24 -9
- package/src/formTypes.ts +62 -38
- package/src/formView.ts +92 -58
- package/src/index.ts +1 -7
- package/src/path.ts +32 -34
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ Values, validation, field metadata, list operations, submit flow, and correlatio
|
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
`@playfast/reform-forms` owns everything a form needs
|
|
16
|
+
`@playfast/reform-forms` owns everything a form needs _except_ its widgets. It has no DOM and no React API — it produces a typed, platform-neutral view that any renderer can map. Pair it with [`@playfast/reform-forms-react`](https://www.npmjs.com/package/@playfast/reform-forms-react) for typed JSX, or consume the view directly from a custom host.
|
|
17
17
|
|
|
18
18
|
## Install
|
|
19
19
|
|
|
@@ -61,9 +61,7 @@ const CheckoutFormLive = Form.live(CheckoutForm, {
|
|
|
61
61
|
items: { minItems: 1, maxItems: inputs.settings.maxItems },
|
|
62
62
|
}),
|
|
63
63
|
validate: ({ values }) =>
|
|
64
|
-
values.email.includes('@')
|
|
65
|
-
? undefined
|
|
66
|
-
: Form.error('email', 'Email must contain @'),
|
|
64
|
+
values.email.includes('@') ? undefined : Form.error('email', 'Email must contain @'),
|
|
67
65
|
submit: ({ decoded, inputs }) => saveCheckout(decoded, inputs.user.id),
|
|
68
66
|
})
|
|
69
67
|
```
|
|
@@ -71,7 +69,7 @@ const CheckoutFormLive = Form.live(CheckoutForm, {
|
|
|
71
69
|
Read the form inside compositions with `Form.view(CheckoutForm)` — a typed, platform-neutral object:
|
|
72
70
|
|
|
73
71
|
```ts
|
|
74
|
-
const form = yield* Form.view(CheckoutForm)
|
|
72
|
+
const form = yield * Form.view(CheckoutForm)
|
|
75
73
|
|
|
76
74
|
form.field('email').set('a@example.com')
|
|
77
75
|
form.array('items').append({ name: 'Milk', qty: 1 })
|
package/package.json
CHANGED
|
@@ -1,37 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playfast/reform-forms",
|
|
3
|
-
"
|
|
4
|
-
"version": "1.1.1",
|
|
5
|
-
"type": "module",
|
|
3
|
+
"version": "1.2.1",
|
|
6
4
|
"description": "Headless, schema-driven form state for reform — values, validation, field limitations, list operations, and submit flow, rendering nothing.",
|
|
7
5
|
"keywords": [
|
|
8
|
-
"reform",
|
|
9
6
|
"effect",
|
|
10
|
-
"forms",
|
|
11
7
|
"form-state",
|
|
12
|
-
"
|
|
8
|
+
"forms",
|
|
9
|
+
"headless",
|
|
10
|
+
"reform",
|
|
13
11
|
"schema",
|
|
14
|
-
"
|
|
12
|
+
"validation"
|
|
15
13
|
],
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/playfast/reform/issues"
|
|
16
|
+
},
|
|
16
17
|
"license": "MIT",
|
|
17
18
|
"repository": {
|
|
18
19
|
"type": "git",
|
|
19
20
|
"url": "https://github.com/playfast/reform.git",
|
|
20
21
|
"directory": "packages/forms"
|
|
21
22
|
},
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"README.md"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
25
28
|
"sideEffects": false,
|
|
26
29
|
"exports": {
|
|
27
30
|
"./package.json": "./package.json",
|
|
28
31
|
".": "./src/index.ts",
|
|
29
32
|
"./*": "./src/*.ts"
|
|
30
33
|
},
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
],
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
35
37
|
"scripts": {
|
|
36
38
|
"clean": "rm -rf dist .tsbuildinfo",
|
|
37
39
|
"check": "tsc --noEmit",
|
|
@@ -43,10 +45,8 @@
|
|
|
43
45
|
"lint:fix": "oxlint --fix src"
|
|
44
46
|
},
|
|
45
47
|
"peerDependencies": {
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
+
"@playfast/reform": "*",
|
|
49
|
+
"effect": "*"
|
|
48
50
|
},
|
|
49
|
-
"
|
|
50
|
-
"access": "public"
|
|
51
|
-
}
|
|
51
|
+
"playbook": "./playbook"
|
|
52
52
|
}
|
package/src/form.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { expect, it } from
|
|
2
|
-
import { Effect, Layer, Option, Schema as S } from
|
|
3
|
-
import { Engine } from
|
|
4
|
-
import * as Form from
|
|
1
|
+
import { expect, it } from '@effect/vitest'
|
|
2
|
+
import { Effect, Layer, Option, Schema as S } from 'effect'
|
|
3
|
+
import { Engine } from '@playfast/reform'
|
|
4
|
+
import * as Form from './form'
|
|
5
5
|
|
|
6
6
|
const waitUntil = <A, R>(
|
|
7
7
|
read: Effect.Effect<A, never, R>,
|
|
@@ -11,81 +11,66 @@ const waitUntil = <A, R>(
|
|
|
11
11
|
Effect.flatMap(read, (value) =>
|
|
12
12
|
pred(value) || rounds <= 0
|
|
13
13
|
? Effect.succeed(value)
|
|
14
|
-
: Effect.flatMap(Effect.yieldNow(), () =>
|
|
15
|
-
|
|
16
|
-
),
|
|
17
|
-
);
|
|
14
|
+
: Effect.flatMap(Effect.yieldNow(), () => waitUntil(read, pred, rounds - 1)),
|
|
15
|
+
)
|
|
18
16
|
|
|
19
17
|
const CheckoutSchema = S.Struct({
|
|
20
18
|
email: S.String,
|
|
21
19
|
items: S.Array(S.Struct({ name: S.String, qty: S.Number })),
|
|
22
20
|
payment: S.Union(
|
|
23
|
-
S.TaggedStruct(
|
|
24
|
-
S.TaggedStruct(
|
|
21
|
+
S.TaggedStruct('Card', { cardNumber: S.String }),
|
|
22
|
+
S.TaggedStruct('Paypal', { email: S.String }),
|
|
25
23
|
),
|
|
26
|
-
})
|
|
24
|
+
})
|
|
27
25
|
|
|
28
|
-
class CheckoutForm extends Form.make(
|
|
26
|
+
class CheckoutForm extends Form.make('CheckoutForm', {
|
|
29
27
|
schema: CheckoutSchema,
|
|
30
28
|
}) {}
|
|
31
29
|
|
|
32
30
|
const layer = Form.live(CheckoutForm, {
|
|
33
31
|
initial: {
|
|
34
|
-
email:
|
|
32
|
+
email: '',
|
|
35
33
|
items: [],
|
|
36
|
-
payment: { _tag:
|
|
34
|
+
payment: { _tag: 'Card', cardNumber: '' },
|
|
37
35
|
},
|
|
38
36
|
validate: ({ values }) =>
|
|
39
|
-
values.email.includes(
|
|
40
|
-
|
|
41
|
-
: Form.error("email", "Email must contain @"),
|
|
42
|
-
}).pipe(Layer.provideMerge(Engine));
|
|
37
|
+
values.email.includes('@') ? undefined : Form.error('email', 'Email must contain @'),
|
|
38
|
+
}).pipe(Layer.provideMerge(Engine))
|
|
43
39
|
|
|
44
|
-
it.scopedLive(
|
|
40
|
+
it.scopedLive('field bindings write through the form reducer', () =>
|
|
45
41
|
Effect.gen(function* () {
|
|
46
|
-
const view = yield* Form.view(CheckoutForm)
|
|
47
|
-
view.field(
|
|
42
|
+
const view = yield* Form.view(CheckoutForm)
|
|
43
|
+
view.field('email').set('a@example.com')
|
|
48
44
|
|
|
49
45
|
const next = yield* waitUntil(
|
|
50
46
|
Form.view(CheckoutForm),
|
|
51
|
-
(v) => v.values.email ===
|
|
52
|
-
)
|
|
53
|
-
expect(next.field(
|
|
47
|
+
(v) => v.values.email === 'a@example.com',
|
|
48
|
+
)
|
|
49
|
+
expect(next.field('email').dirty).toBe(true)
|
|
54
50
|
}).pipe(Effect.provide(layer)),
|
|
55
|
-
)
|
|
51
|
+
)
|
|
56
52
|
|
|
57
|
-
it.scopedLive(
|
|
53
|
+
it.scopedLive('array bindings append and remove items', () =>
|
|
58
54
|
Effect.gen(function* () {
|
|
59
|
-
const view = yield* Form.view(CheckoutForm)
|
|
60
|
-
view.array(
|
|
55
|
+
const view = yield* Form.view(CheckoutForm)
|
|
56
|
+
view.array('items').append({ name: 'milk', qty: 1 })
|
|
61
57
|
|
|
62
|
-
const withItem = yield* waitUntil(
|
|
63
|
-
|
|
64
|
-
(v) => v.values.items.length === 1,
|
|
65
|
-
);
|
|
66
|
-
expect(withItem.array("items").items[0]?.key).toMatch(/^form-item-/);
|
|
58
|
+
const withItem = yield* waitUntil(Form.view(CheckoutForm), (v) => v.values.items.length === 1)
|
|
59
|
+
expect(withItem.array('items').items[0]?.key).toMatch(/^form-item-/)
|
|
67
60
|
|
|
68
|
-
withItem.array(
|
|
69
|
-
const empty = yield* waitUntil(
|
|
70
|
-
|
|
71
|
-
(v) => v.values.items.length === 0,
|
|
72
|
-
);
|
|
73
|
-
expect(empty.values.items).toEqual([]);
|
|
61
|
+
withItem.array('items').remove(0)
|
|
62
|
+
const empty = yield* waitUntil(Form.view(CheckoutForm), (v) => v.values.items.length === 0)
|
|
63
|
+
expect(empty.values.items).toEqual([])
|
|
74
64
|
}).pipe(Effect.provide(layer)),
|
|
75
|
-
)
|
|
65
|
+
)
|
|
76
66
|
|
|
77
|
-
it.scopedLive(
|
|
67
|
+
it.scopedLive('submit routes custom validation errors to fields', () =>
|
|
78
68
|
Effect.gen(function* () {
|
|
79
|
-
const view = yield* Form.view(CheckoutForm)
|
|
80
|
-
view.submit()
|
|
69
|
+
const view = yield* Form.view(CheckoutForm)
|
|
70
|
+
view.submit()
|
|
81
71
|
|
|
82
|
-
const invalid = yield* waitUntil(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
);
|
|
86
|
-
const error = invalid.field("email").error;
|
|
87
|
-
expect(Option.isSome(error) ? error.value : undefined).toBe(
|
|
88
|
-
"Email must contain @",
|
|
89
|
-
);
|
|
72
|
+
const invalid = yield* waitUntil(Form.view(CheckoutForm), (v) => v.validationCount > 0)
|
|
73
|
+
const error = invalid.field('email').error
|
|
74
|
+
expect(Option.isSome(error) ? error.value : undefined).toBe('Email must contain @')
|
|
90
75
|
}).pipe(Effect.provide(layer)),
|
|
91
|
-
)
|
|
76
|
+
)
|
package/src/form.ts
CHANGED
|
@@ -1,51 +1,31 @@
|
|
|
1
|
+
import { type Context, Effect, Function as Fn, Layer, Option, Schema as S } from 'effect'
|
|
2
|
+
import { Reducer, State } from '@playfast/reform'
|
|
3
|
+
import { Bus, Reducers } from '@playfast/reform/internal'
|
|
4
|
+
import { error, normalizeErrors, type FormErrors } from './validation'
|
|
1
5
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
Bus,
|
|
11
|
-
Event,
|
|
12
|
-
Reducer,
|
|
13
|
-
Reducers,
|
|
14
|
-
State,
|
|
15
|
-
type Store,
|
|
16
|
-
} from '@playfast/reform'
|
|
6
|
+
appendAtPath,
|
|
7
|
+
initialState,
|
|
8
|
+
markTouched,
|
|
9
|
+
moveAtPath,
|
|
10
|
+
removeAtPath,
|
|
11
|
+
setAtPath,
|
|
12
|
+
swapAtPath,
|
|
13
|
+
} from './formState'
|
|
17
14
|
import type {
|
|
18
|
-
AnyForm,
|
|
19
|
-
Decoded,
|
|
20
15
|
DecodedOfSchema,
|
|
21
16
|
FormClass,
|
|
22
|
-
FormEvents,
|
|
23
17
|
FormLiveConfig,
|
|
24
18
|
FormState,
|
|
25
19
|
InputRecord,
|
|
26
|
-
Inputs,
|
|
27
20
|
InputsObject,
|
|
28
21
|
RuntimeConfig,
|
|
29
|
-
Values,
|
|
30
22
|
ValuesOfSchema,
|
|
31
23
|
} from './formTypes'
|
|
32
|
-
import {
|
|
33
|
-
appendAtPath,
|
|
34
|
-
initialState,
|
|
35
|
-
markTouched,
|
|
36
|
-
moveAtPath,
|
|
37
|
-
removeAtPath,
|
|
38
|
-
setAtPath,
|
|
39
|
-
swapAtPath,
|
|
40
|
-
} from './formState'
|
|
41
|
-
import {
|
|
42
|
-
error,
|
|
43
|
-
normalizeErrors,
|
|
44
|
-
type FormErrors,
|
|
45
|
-
} from './validation'
|
|
46
24
|
|
|
47
25
|
export { error }
|
|
48
|
-
|
|
26
|
+
export { arrayKeysFor } from './formState'
|
|
27
|
+
export { make } from './formMake'
|
|
28
|
+
export { view } from './formView'
|
|
49
29
|
export type {
|
|
50
30
|
AnyForm,
|
|
51
31
|
ArrayBinding,
|
|
@@ -57,8 +37,11 @@ export type {
|
|
|
57
37
|
FormClass,
|
|
58
38
|
FormLiveConfig,
|
|
59
39
|
FormManifest,
|
|
40
|
+
FormManifestReflection,
|
|
41
|
+
FormSchemaReflection,
|
|
60
42
|
FormState,
|
|
61
43
|
FormView,
|
|
44
|
+
FormVisitor,
|
|
62
45
|
Inputs,
|
|
63
46
|
Limitations,
|
|
64
47
|
RuntimeConfig,
|
|
@@ -66,88 +49,43 @@ export type {
|
|
|
66
49
|
View,
|
|
67
50
|
} from './formTypes'
|
|
68
51
|
|
|
69
|
-
const unknownSchema: S.Schema.AnyNoContext = Fn.unsafeCoerce(S.Unknown)
|
|
70
|
-
|
|
71
|
-
export { arrayKeysFor } from './formState'
|
|
72
|
-
|
|
73
|
-
interface MakeConfig<Schema extends S.Schema.Any, Inputs extends InputRecord> {
|
|
74
|
-
readonly schema: Schema
|
|
75
|
-
// oxlint-disable-next-line reform-rules/no-optional-fields -- optional source map on the public make() config; Option would break callers passing only a schema
|
|
76
|
-
readonly inputs?: Inputs
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export const make = <
|
|
80
|
-
const N extends string,
|
|
81
|
-
Schema extends S.Schema.Any,
|
|
82
|
-
const Inputs extends InputRecord = {},
|
|
83
|
-
>(
|
|
84
|
-
name: N,
|
|
85
|
-
config: MakeConfig<Schema, Inputs>,
|
|
86
|
-
): FormClass<N, Schema, Inputs> => {
|
|
87
|
-
const state: State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>> = Fn.unsafeCoerce(
|
|
88
|
-
State.make(`${name}/state`, unknownSchema, { title: `${name} form state` }),
|
|
89
|
-
)
|
|
90
|
-
const events: FormEvents = {
|
|
91
|
-
set: Event.make(`${name}/SetField`, S.Struct({ path: S.String, value: S.Unknown })),
|
|
92
|
-
blur: Event.make(`${name}/BlurField`, S.Struct({ path: S.String })),
|
|
93
|
-
reset: Event.make(`${name}/Reset`, S.Struct({})),
|
|
94
|
-
validationFinished: Event.make(`${name}/ValidationFinished`, S.Struct({
|
|
95
|
-
errors: S.Record({ key: S.String, value: S.String }),
|
|
96
|
-
})),
|
|
97
|
-
submitAttempted: Event.make(`${name}/SubmitAttempted`, S.Struct({})),
|
|
98
|
-
submitSucceeded: Event.make(`${name}/SubmitSucceeded`, S.Struct({ values: S.Unknown })),
|
|
99
|
-
append: Event.make(`${name}/AppendItem`, S.Struct({ path: S.String, value: S.Unknown })),
|
|
100
|
-
remove: Event.make(`${name}/RemoveItem`, S.Struct({ path: S.String, index: S.Number })),
|
|
101
|
-
move: Event.make(`${name}/MoveItem`, S.Struct({ path: S.String, from: S.Number, to: S.Number })),
|
|
102
|
-
swap: Event.make(`${name}/SwapItems`, S.Struct({ path: S.String, a: S.Number, b: S.Number })),
|
|
103
|
-
}
|
|
104
|
-
const reducer = Reducer.make(`${name}/FormReducer`, {
|
|
105
|
-
states: [state],
|
|
106
|
-
events: Object.values(events),
|
|
107
|
-
})
|
|
108
|
-
const runtimeConfig = Context.GenericTag<
|
|
109
|
-
RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>,
|
|
110
|
-
RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>
|
|
111
|
-
>(`reform/form/${name}/config`)
|
|
112
|
-
const inputs: Inputs = Fn.unsafeCoerce(Option.getOrElse(Option.fromNullable(config.inputs), () => ({})))
|
|
113
|
-
class FormImpl {
|
|
114
|
-
static readonly manifest = {
|
|
115
|
-
kind: 'Form' as const,
|
|
116
|
-
name,
|
|
117
|
-
schema: config.schema,
|
|
118
|
-
inputs,
|
|
119
|
-
}
|
|
120
|
-
static readonly state = state
|
|
121
|
-
static readonly config = runtimeConfig
|
|
122
|
-
static readonly events = events
|
|
123
|
-
static readonly reducer = reducer
|
|
124
|
-
}
|
|
125
|
-
return Fn.unsafeCoerce(FormImpl)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
52
|
export const live = <
|
|
129
|
-
|
|
53
|
+
N extends string,
|
|
54
|
+
Schema extends S.Schema.AnyNoContext,
|
|
55
|
+
FormInputs extends InputRecord,
|
|
130
56
|
R = never,
|
|
131
57
|
>(
|
|
132
|
-
form:
|
|
133
|
-
config: FormLiveConfig<
|
|
58
|
+
form: FormClass<N, Schema, FormInputs>,
|
|
59
|
+
config: FormLiveConfig<
|
|
60
|
+
ValuesOfSchema<Schema>,
|
|
61
|
+
DecodedOfSchema<Schema>,
|
|
62
|
+
InputsObject<FormInputs>,
|
|
63
|
+
R
|
|
64
|
+
>,
|
|
134
65
|
): Layer.Layer<
|
|
135
|
-
|
|
66
|
+
| State.StateStore<`${N}/state`, FormState<ValuesOfSchema<Schema>>>
|
|
67
|
+
| RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<FormInputs>>,
|
|
136
68
|
never,
|
|
137
69
|
Bus | Reducers | R
|
|
138
70
|
> => {
|
|
139
71
|
const configTag: Context.Tag<
|
|
140
|
-
RuntimeConfig<
|
|
141
|
-
RuntimeConfig<
|
|
142
|
-
> =
|
|
72
|
+
RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<FormInputs>>,
|
|
73
|
+
RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<FormInputs>>
|
|
74
|
+
> = form.config
|
|
143
75
|
const configLayer = Layer.effect(
|
|
144
76
|
configTag,
|
|
145
77
|
Effect.map(Effect.context<R | Bus>(), (context) => {
|
|
146
|
-
const runtimeConfig: RuntimeConfig<
|
|
78
|
+
const runtimeConfig: RuntimeConfig<
|
|
79
|
+
ValuesOfSchema<Schema>,
|
|
80
|
+
DecodedOfSchema<Schema>,
|
|
81
|
+
InputsObject<FormInputs>
|
|
82
|
+
> = {
|
|
147
83
|
initial: config.initial,
|
|
148
84
|
validate: (ctx) => {
|
|
149
85
|
const validationOutput = config.validate?.(ctx)
|
|
150
|
-
const effect = Effect.isEffect(validationOutput)
|
|
86
|
+
const effect = Effect.isEffect(validationOutput)
|
|
87
|
+
? validationOutput
|
|
88
|
+
: Effect.succeed(validationOutput)
|
|
151
89
|
return Fn.unsafeCoerce(
|
|
152
90
|
Effect.map(Effect.provide(effect, context), (rawErrors) => normalizeErrors(rawErrors)),
|
|
153
91
|
)
|
|
@@ -158,9 +96,7 @@ export const live = <
|
|
|
158
96
|
return Fn.unsafeCoerce(Effect.asVoid(Effect.provide(effect, context)))
|
|
159
97
|
},
|
|
160
98
|
}
|
|
161
|
-
return config.limit === undefined
|
|
162
|
-
? runtimeConfig
|
|
163
|
-
: { ...runtimeConfig, limit: config.limit }
|
|
99
|
+
return config.limit === undefined ? runtimeConfig : { ...runtimeConfig, limit: config.limit }
|
|
164
100
|
}),
|
|
165
101
|
)
|
|
166
102
|
|
|
@@ -176,12 +112,19 @@ export const live = <
|
|
|
176
112
|
|
|
177
113
|
const reducerLayer = Reducer.live(
|
|
178
114
|
form.reducer,
|
|
179
|
-
(
|
|
115
|
+
(
|
|
116
|
+
state: FormState<ValuesOfSchema<Schema>>,
|
|
117
|
+
event: InternalEvent,
|
|
118
|
+
): FormState<ValuesOfSchema<Schema>> => {
|
|
180
119
|
if (event._tag === form.events.set.tag) {
|
|
181
|
-
return 'path' in event && 'value' in event
|
|
120
|
+
return 'path' in event && 'value' in event
|
|
121
|
+
? setAtPath({ state, path: event.path, value: event.value })
|
|
122
|
+
: state
|
|
182
123
|
}
|
|
183
124
|
if (event._tag === form.events.blur.tag) {
|
|
184
|
-
return 'path' in event
|
|
125
|
+
return 'path' in event
|
|
126
|
+
? { ...state, touched: markTouched(state.touched, event.path) }
|
|
127
|
+
: state
|
|
185
128
|
}
|
|
186
129
|
if (event._tag === form.events.reset.tag) {
|
|
187
130
|
return initialState(state.initialValues)
|
|
@@ -195,9 +138,15 @@ export const live = <
|
|
|
195
138
|
return { ...state, submitCount: state.submitCount + 1 }
|
|
196
139
|
}
|
|
197
140
|
if (event._tag === form.events.submitSucceeded.tag) {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
141
|
+
if (!('values' in event)) {
|
|
142
|
+
return state
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
...state,
|
|
146
|
+
lastSubmittedValues: Option.some(
|
|
147
|
+
Fn.unsafeCoerce<unknown, ValuesOfSchema<Schema>>(event.values),
|
|
148
|
+
),
|
|
149
|
+
}
|
|
201
150
|
}
|
|
202
151
|
if (event._tag === form.events.append.tag) {
|
|
203
152
|
return 'path' in event && 'value' in event
|
|
@@ -223,11 +172,7 @@ export const live = <
|
|
|
223
172
|
},
|
|
224
173
|
)
|
|
225
174
|
|
|
226
|
-
return
|
|
227
|
-
Layer.
|
|
228
|
-
Layer.provideMerge(State.live(form.state, initialState(config.initial))),
|
|
229
|
-
),
|
|
175
|
+
return Layer.mergeAll(configLayer, reducerLayer).pipe(
|
|
176
|
+
Layer.provideMerge(State.live(form.state, initialState(config.initial))),
|
|
230
177
|
)
|
|
231
178
|
}
|
|
232
|
-
|
|
233
|
-
export { view } from './formView'
|
package/src/formMake.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { Context, Function as Fn, Option, Schema as S } from 'effect'
|
|
2
|
+
import { Event, Reducer, State } from '@playfast/reform'
|
|
3
|
+
import type {
|
|
4
|
+
DecodedOfSchema,
|
|
5
|
+
FormClass,
|
|
6
|
+
FormEvents,
|
|
7
|
+
FormManifest,
|
|
8
|
+
FormState,
|
|
9
|
+
FormVisitor,
|
|
10
|
+
InputRecord,
|
|
11
|
+
InputsObject,
|
|
12
|
+
RuntimeConfig,
|
|
13
|
+
ValuesOfSchema,
|
|
14
|
+
} from './formTypes'
|
|
15
|
+
|
|
16
|
+
interface MakeConfig<Schema extends S.Schema.AnyNoContext, Inputs extends InputRecord> {
|
|
17
|
+
readonly schema: Schema
|
|
18
|
+
// oxlint-disable-next-line reform-rules/no-optional-fields -- optional source map on the public make() config; Option would break callers passing only a schema
|
|
19
|
+
readonly inputs?: Inputs
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const make = <
|
|
23
|
+
const N extends string,
|
|
24
|
+
Schema extends S.Schema.AnyNoContext,
|
|
25
|
+
const Inputs extends InputRecord = {},
|
|
26
|
+
>(
|
|
27
|
+
name: N,
|
|
28
|
+
config: MakeConfig<Schema, Inputs>,
|
|
29
|
+
): FormClass<N, Schema, Inputs> => {
|
|
30
|
+
const valuesSchema = S.encodedSchema(config.schema)
|
|
31
|
+
const stringRecord = S.Record({ key: S.String, value: S.String })
|
|
32
|
+
const stateSchema = S.Struct({
|
|
33
|
+
values: valuesSchema,
|
|
34
|
+
initialValues: valuesSchema,
|
|
35
|
+
touched: S.Record({ key: S.String, value: S.Boolean }),
|
|
36
|
+
errors: stringRecord,
|
|
37
|
+
dirtyPaths: S.Array(S.String),
|
|
38
|
+
submitCount: S.Number,
|
|
39
|
+
validationCount: S.Number,
|
|
40
|
+
lastSubmittedValues: S.OptionFromSelf(valuesSchema),
|
|
41
|
+
arrayKeys: S.Record({ key: S.String, value: S.Array(S.String) }),
|
|
42
|
+
})
|
|
43
|
+
const state: State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>> = State.make(
|
|
44
|
+
`${name}/state`,
|
|
45
|
+
stateSchema,
|
|
46
|
+
{ title: `${name} form state` },
|
|
47
|
+
)
|
|
48
|
+
const events: FormEvents = {
|
|
49
|
+
set: Event.make(`${name}/SetField`, S.Struct({ path: S.String, value: S.Unknown })),
|
|
50
|
+
blur: Event.make(`${name}/BlurField`, S.Struct({ path: S.String })),
|
|
51
|
+
reset: Event.make(`${name}/Reset`, S.Struct({})),
|
|
52
|
+
validationFinished: Event.make(
|
|
53
|
+
`${name}/ValidationFinished`,
|
|
54
|
+
S.Struct({
|
|
55
|
+
errors: S.Record({ key: S.String, value: S.String }),
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
submitAttempted: Event.make(`${name}/SubmitAttempted`, S.Struct({})),
|
|
59
|
+
submitSucceeded: Event.make(`${name}/SubmitSucceeded`, S.Struct({ values: S.Unknown })),
|
|
60
|
+
append: Event.make(`${name}/AppendItem`, S.Struct({ path: S.String, value: S.Unknown })),
|
|
61
|
+
remove: Event.make(`${name}/RemoveItem`, S.Struct({ path: S.String, index: S.Number })),
|
|
62
|
+
move: Event.make(
|
|
63
|
+
`${name}/MoveItem`,
|
|
64
|
+
S.Struct({ path: S.String, from: S.Number, to: S.Number }),
|
|
65
|
+
),
|
|
66
|
+
swap: Event.make(`${name}/SwapItems`, S.Struct({ path: S.String, a: S.Number, b: S.Number })),
|
|
67
|
+
}
|
|
68
|
+
const reducer: FormClass<N, Schema, Inputs>['reducer'] = Reducer.make(`${name}/FormReducer`, {
|
|
69
|
+
states: [state],
|
|
70
|
+
events: Object.values(events),
|
|
71
|
+
})
|
|
72
|
+
const runtimeConfig = Context.GenericTag<
|
|
73
|
+
RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>,
|
|
74
|
+
RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>
|
|
75
|
+
>(`reform/form/${name}/config`)
|
|
76
|
+
const inputs: Inputs = Fn.unsafeCoerce(
|
|
77
|
+
Option.getOrElse(Option.fromNullable(config.inputs), () => ({})),
|
|
78
|
+
)
|
|
79
|
+
class FormImpl {
|
|
80
|
+
static readonly manifest: FormManifest<N, Schema, Inputs> = {
|
|
81
|
+
kind: 'Form',
|
|
82
|
+
name,
|
|
83
|
+
schema: config.schema,
|
|
84
|
+
inputs,
|
|
85
|
+
}
|
|
86
|
+
static readonly state = state
|
|
87
|
+
static readonly config = runtimeConfig
|
|
88
|
+
static readonly events = events
|
|
89
|
+
static readonly reducer = reducer
|
|
90
|
+
static capture<Result>(visitor: FormVisitor<Result>): Result {
|
|
91
|
+
return visitor.visit(FormImpl)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return FormImpl
|
|
95
|
+
}
|
package/src/formState.ts
CHANGED
|
@@ -16,12 +16,15 @@ export const arrayKeysFor = (source: unknown, path = ''): Record<string, Readonl
|
|
|
16
16
|
const visit = (node: KeyVisitNode): void => {
|
|
17
17
|
if (Array.isArray(node.current)) {
|
|
18
18
|
out[node.path] = node.current.map(() => makeArrayKey())
|
|
19
|
-
node.current.forEach((element, index) =>
|
|
19
|
+
node.current.forEach((element, index) =>
|
|
20
|
+
visit({ current: element, path: `${node.path}[${index}]` }),
|
|
21
|
+
)
|
|
20
22
|
return
|
|
21
23
|
}
|
|
22
24
|
if (node.current !== null && typeof node.current === 'object') {
|
|
23
|
-
Record.toEntries(Fn.unsafeCoerce<unknown, Record<string, unknown>>(node.current)).forEach(
|
|
24
|
-
|
|
25
|
+
Record.toEntries(Fn.unsafeCoerce<unknown, Record<string, unknown>>(node.current)).forEach(
|
|
26
|
+
([key, child]) =>
|
|
27
|
+
visit({ current: child, path: node.path.length === 0 ? key : `${node.path}.${key}` }),
|
|
25
28
|
)
|
|
26
29
|
}
|
|
27
30
|
}
|
|
@@ -41,8 +44,10 @@ export const initialState = <Values>(initial: Values): FormState<Values> => ({
|
|
|
41
44
|
arrayKeys: arrayKeysFor(initial),
|
|
42
45
|
})
|
|
43
46
|
|
|
44
|
-
export const markTouched = (
|
|
45
|
-
|
|
47
|
+
export const markTouched = (
|
|
48
|
+
touched: Readonly<Record<string, boolean>>,
|
|
49
|
+
path: string,
|
|
50
|
+
): Readonly<Record<string, boolean>> => ({ ...touched, [path]: true })
|
|
46
51
|
|
|
47
52
|
interface ArrayLookup {
|
|
48
53
|
readonly source: unknown
|
|
@@ -93,7 +98,8 @@ const updateKeys = (
|
|
|
93
98
|
path: string,
|
|
94
99
|
transform: (keys: ReadonlyArray<string>) => ReadonlyArray<string>,
|
|
95
100
|
): Readonly<Record<string, ReadonlyArray<string>>> => {
|
|
96
|
-
const existing =
|
|
101
|
+
const existing =
|
|
102
|
+
state.arrayKeys[path] ?? currentArray({ source: state.values, path }).map(() => makeArrayKey())
|
|
97
103
|
return { ...state.arrayKeys, [path]: transform(existing) }
|
|
98
104
|
}
|
|
99
105
|
|
|
@@ -105,7 +111,10 @@ export const appendAtPath = <Values>(input: SetPathInput<Values>): FormState<Val
|
|
|
105
111
|
const nextValues = setNestedValue(input.state.values, input.path, [...elements, input.value])
|
|
106
112
|
return {
|
|
107
113
|
...withValues(input.state, nextValues),
|
|
108
|
-
arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => [
|
|
114
|
+
arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => [
|
|
115
|
+
...keys,
|
|
116
|
+
makeArrayKey(),
|
|
117
|
+
]),
|
|
109
118
|
}
|
|
110
119
|
}
|
|
111
120
|
|
|
@@ -136,7 +145,9 @@ export const moveAtPath = <Values>(input: MovePathInput<Values>): FormState<Valu
|
|
|
136
145
|
const nextValues = setNestedValue(input.state.values, input.path, next)
|
|
137
146
|
return {
|
|
138
147
|
...withValues(input.state, nextValues),
|
|
139
|
-
arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) =>
|
|
148
|
+
arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) =>
|
|
149
|
+
moveAt(keys, input.from, input.to),
|
|
150
|
+
),
|
|
140
151
|
}
|
|
141
152
|
}
|
|
142
153
|
|
|
@@ -151,7 +162,11 @@ export const swapAtPath = <Values>(input: SwapPathInput<Values>): FormState<Valu
|
|
|
151
162
|
) {
|
|
152
163
|
return input.state
|
|
153
164
|
}
|
|
154
|
-
const swapped = replaceAt(
|
|
165
|
+
const swapped = replaceAt(
|
|
166
|
+
replaceAt(elements, input.first, elements[input.second]),
|
|
167
|
+
input.second,
|
|
168
|
+
elements[input.first],
|
|
169
|
+
)
|
|
155
170
|
const nextValues = setNestedValue(input.state.values, input.path, swapped)
|
|
156
171
|
return {
|
|
157
172
|
...withValues(input.state, nextValues),
|
package/src/formTypes.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import { Context, Effect, Option, Schema as S } from 'effect'
|
|
2
|
-
import type {
|
|
1
|
+
import type { Context, Effect, Option, Schema as S } from 'effect'
|
|
2
|
+
import type { Event, Reducer, State } from '@playfast/reform'
|
|
3
|
+
import type {
|
|
4
|
+
AnySource,
|
|
5
|
+
SourceName,
|
|
6
|
+
SourceValue as ReformSourceValue,
|
|
7
|
+
} from '@playfast/reform/internal'
|
|
3
8
|
import type {
|
|
4
9
|
ArrayItem,
|
|
5
10
|
ArrayPath,
|
|
@@ -10,9 +15,6 @@ import type {
|
|
|
10
15
|
} from './path'
|
|
11
16
|
import type { FormError, FormErrors } from './validation'
|
|
12
17
|
|
|
13
|
-
export type AnyValue = S.Schema.Type<S.Schema.Any>
|
|
14
|
-
|
|
15
|
-
// Wire-serialized UI knobs: ExternalApi postfix allows optional fields (Option would break the contract).
|
|
16
18
|
export interface FieldLimitationsExternalApi<A = unknown> {
|
|
17
19
|
readonly required?: boolean
|
|
18
20
|
readonly disabled?: boolean
|
|
@@ -93,30 +95,25 @@ export interface FormView<Values, Inputs = {}> {
|
|
|
93
95
|
|
|
94
96
|
export type InputRecord = Readonly<Record<string, AnySource>>
|
|
95
97
|
|
|
96
|
-
// Structural `S['name']`: Source value param is invariant, so `Source<_, unknown>` erases keys to never.
|
|
97
|
-
export type SourceName<S extends AnySource> = S['name']
|
|
98
|
-
export type SourceValue<S> = S extends Source<string, infer A> ? A : never
|
|
99
|
-
|
|
100
98
|
export type InputsObject<Inputs extends InputRecord> = {
|
|
101
|
-
readonly [K in keyof Inputs as SourceName<Inputs[K]>]:
|
|
99
|
+
readonly [K in keyof Inputs as SourceName<Inputs[K]>]: ReformSourceValue<Inputs[K]>
|
|
102
100
|
}
|
|
103
101
|
|
|
104
102
|
export type InputStores<Inputs extends InputRecord> = {
|
|
105
|
-
[K in keyof Inputs]: Inputs[K] extends {
|
|
103
|
+
[K in keyof Inputs]: Inputs[K] extends {
|
|
104
|
+
readonly store: Context.Tag<infer Service, infer _Store>
|
|
105
|
+
}
|
|
106
106
|
? Service
|
|
107
107
|
: never
|
|
108
108
|
}[keyof Inputs]
|
|
109
109
|
|
|
110
|
-
export type ValuesOfSchema<
|
|
111
|
-
export type DecodedOfSchema<
|
|
110
|
+
export type ValuesOfSchema<Schema extends S.Schema.AnyNoContext> = S.Schema.Encoded<Schema>
|
|
111
|
+
export type DecodedOfSchema<Schema extends S.Schema.AnyNoContext> = S.Schema.Type<Schema>
|
|
112
112
|
|
|
113
113
|
export interface RuntimeConfig<Values, Decoded, Inputs> {
|
|
114
114
|
readonly initial: Values
|
|
115
115
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob (not a serializable shape); Option would break external form definitions
|
|
116
|
-
readonly limit?: (ctx: {
|
|
117
|
-
readonly values: Values
|
|
118
|
-
readonly inputs: Inputs
|
|
119
|
-
}) => Limitations
|
|
116
|
+
readonly limit?: (ctx: { readonly values: Values; readonly inputs: Inputs }) => Limitations
|
|
120
117
|
readonly validate: (ctx: {
|
|
121
118
|
readonly values: Values
|
|
122
119
|
readonly decoded: Decoded
|
|
@@ -132,10 +129,7 @@ export interface RuntimeConfig<Values, Decoded, Inputs> {
|
|
|
132
129
|
export interface FormLiveConfig<Values, Decoded, Inputs, R = never> {
|
|
133
130
|
readonly initial: Values
|
|
134
131
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
|
|
135
|
-
readonly limit?: (ctx: {
|
|
136
|
-
readonly values: Values
|
|
137
|
-
readonly inputs: Inputs
|
|
138
|
-
}) => Limitations
|
|
132
|
+
readonly limit?: (ctx: { readonly values: Values; readonly inputs: Inputs }) => Limitations
|
|
139
133
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
|
|
140
134
|
readonly validate?: (ctx: {
|
|
141
135
|
readonly values: Values
|
|
@@ -154,9 +148,22 @@ export interface FormLiveConfig<Values, Decoded, Inputs, R = never> {
|
|
|
154
148
|
}) => void | Effect.Effect<unknown, unknown, R>
|
|
155
149
|
}
|
|
156
150
|
|
|
157
|
-
export interface
|
|
151
|
+
export interface FormSchemaReflection {
|
|
152
|
+
readonly ast: S.Schema<unknown, unknown, unknown>['ast']
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface FormManifestReflection<N extends string> {
|
|
158
156
|
readonly kind: 'Form'
|
|
159
157
|
readonly name: N
|
|
158
|
+
readonly schema: FormSchemaReflection
|
|
159
|
+
readonly inputs: InputRecord
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface FormManifest<
|
|
163
|
+
N extends string,
|
|
164
|
+
Schema extends S.Schema.AnyNoContext,
|
|
165
|
+
Inputs extends InputRecord,
|
|
166
|
+
> extends FormManifestReflection<N> {
|
|
160
167
|
readonly schema: Schema
|
|
161
168
|
readonly inputs: Inputs
|
|
162
169
|
}
|
|
@@ -170,17 +177,40 @@ export interface FormEvents {
|
|
|
170
177
|
readonly submitSucceeded: Event.EventClass<string, { readonly values: unknown }>
|
|
171
178
|
readonly append: Event.EventClass<string, { readonly path: string; readonly value: unknown }>
|
|
172
179
|
readonly remove: Event.EventClass<string, { readonly path: string; readonly index: number }>
|
|
173
|
-
readonly move: Event.EventClass<
|
|
174
|
-
|
|
180
|
+
readonly move: Event.EventClass<
|
|
181
|
+
string,
|
|
182
|
+
{ readonly path: string; readonly from: number; readonly to: number }
|
|
183
|
+
>
|
|
184
|
+
readonly swap: Event.EventClass<
|
|
185
|
+
string,
|
|
186
|
+
{ readonly path: string; readonly a: number; readonly b: number }
|
|
187
|
+
>
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface FormVisitor<Result> {
|
|
191
|
+
readonly visit: <
|
|
192
|
+
N extends string,
|
|
193
|
+
Schema extends S.Schema.AnyNoContext,
|
|
194
|
+
Inputs extends InputRecord,
|
|
195
|
+
>(
|
|
196
|
+
form: FormClass<N, Schema, Inputs>,
|
|
197
|
+
) => Result
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface AnyForm {
|
|
201
|
+
new (): {}
|
|
202
|
+
readonly manifest: FormManifestReflection<string>
|
|
203
|
+
readonly capture: <Result>(visitor: FormVisitor<Result>) => Result
|
|
175
204
|
}
|
|
176
205
|
|
|
177
206
|
export interface FormClass<
|
|
178
207
|
N extends string,
|
|
179
|
-
Schema extends S.Schema.
|
|
208
|
+
Schema extends S.Schema.AnyNoContext,
|
|
180
209
|
Inputs extends InputRecord,
|
|
181
210
|
> {
|
|
182
211
|
new (): {}
|
|
183
212
|
readonly manifest: FormManifest<N, Schema, Inputs>
|
|
213
|
+
readonly capture: <Result>(visitor: FormVisitor<Result>) => Result
|
|
184
214
|
readonly state: State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>>
|
|
185
215
|
readonly config: Context.Tag<
|
|
186
216
|
RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>,
|
|
@@ -193,19 +223,13 @@ export interface FormClass<
|
|
|
193
223
|
>
|
|
194
224
|
}
|
|
195
225
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
export type Values<F extends AnyForm> = F extends FormClass<string, infer Schema, AnyValue>
|
|
200
|
-
? ValuesOfSchema<Schema>
|
|
201
|
-
: never
|
|
226
|
+
export type Values<F> =
|
|
227
|
+
F extends FormClass<infer _Name, infer Schema, infer _Inputs> ? ValuesOfSchema<Schema> : never
|
|
202
228
|
|
|
203
|
-
export type Decoded<F
|
|
204
|
-
? DecodedOfSchema<Schema>
|
|
205
|
-
: never
|
|
229
|
+
export type Decoded<F> =
|
|
230
|
+
F extends FormClass<infer _Name, infer Schema, infer _Inputs> ? DecodedOfSchema<Schema> : never
|
|
206
231
|
|
|
207
|
-
export type Inputs<F
|
|
208
|
-
? InputsObject<Inputs>
|
|
209
|
-
: never
|
|
232
|
+
export type Inputs<F> =
|
|
233
|
+
F extends FormClass<infer _Name, infer _Schema, infer Inputs> ? InputsObject<Inputs> : never
|
|
210
234
|
|
|
211
|
-
export type View<F
|
|
235
|
+
export type View<F> = FormView<Values<F>, Inputs<F>>
|
package/src/formView.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
Context,
|
|
2
3
|
Effect,
|
|
3
4
|
Either,
|
|
4
5
|
Function as Fn,
|
|
@@ -7,49 +8,54 @@ import {
|
|
|
7
8
|
Runtime,
|
|
8
9
|
Schema as S,
|
|
9
10
|
} from 'effect'
|
|
11
|
+
import { Event, type Trigger } from '@playfast/reform'
|
|
10
12
|
import {
|
|
13
|
+
type AnySource,
|
|
11
14
|
Bus,
|
|
12
15
|
CurrentTracker,
|
|
13
|
-
|
|
16
|
+
type SourceIdentifier,
|
|
17
|
+
type SourceValue as ReformSourceValue,
|
|
14
18
|
type Store,
|
|
15
|
-
|
|
16
|
-
} from '@playfast/reform'
|
|
17
|
-
import
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
} from '@playfast/reform/internal'
|
|
20
|
+
import { State } from '@playfast/reform'
|
|
21
|
+
import {
|
|
22
|
+
getNestedValue,
|
|
23
|
+
isPathOrParentDirty,
|
|
24
|
+
type ArrayItem,
|
|
25
|
+
type ArrayPath,
|
|
26
|
+
type FieldPath,
|
|
27
|
+
type PathValue,
|
|
28
|
+
} from './path'
|
|
21
29
|
import { errorsToRecord, firstError, routeParseError } from './validation'
|
|
30
|
+
import { currentArray } from './formState'
|
|
22
31
|
import type {
|
|
23
|
-
AnyForm,
|
|
24
32
|
ArrayBinding,
|
|
25
|
-
|
|
26
|
-
FieldBinding,
|
|
33
|
+
DecodedOfSchema,
|
|
27
34
|
FieldLimitations,
|
|
35
|
+
FieldBinding,
|
|
36
|
+
FormClass,
|
|
28
37
|
FormState,
|
|
38
|
+
FormView,
|
|
29
39
|
InputRecord,
|
|
30
|
-
Inputs,
|
|
31
40
|
InputsObject,
|
|
32
41
|
InputStores,
|
|
33
42
|
Limitations,
|
|
34
43
|
RuntimeConfig,
|
|
35
|
-
|
|
36
|
-
Values,
|
|
37
|
-
View,
|
|
44
|
+
ValuesOfSchema,
|
|
38
45
|
} from './formTypes'
|
|
39
46
|
|
|
40
|
-
const readInput = <
|
|
41
|
-
source:
|
|
42
|
-
): Effect.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const store = yield* source.store
|
|
46
|
-
const tracker = yield* Effect.serviceOption(CurrentTracker)
|
|
47
|
-
if (Option.isSome(tracker)) {
|
|
48
|
-
tracker.value.add(store)
|
|
49
|
-
}
|
|
50
|
-
return store.getSnapshot()
|
|
51
|
-
}),
|
|
47
|
+
const readInput = Effect.fn('forms.readInput')(function* <Input extends AnySource>(
|
|
48
|
+
source: Input,
|
|
49
|
+
): Effect.fn.Return<ReformSourceValue<Input>, never, SourceIdentifier<Input>> {
|
|
50
|
+
const store = yield* Context.GenericTag<SourceIdentifier<Input>, Store<ReformSourceValue<Input>>>(
|
|
51
|
+
source.store.key,
|
|
52
52
|
)
|
|
53
|
+
const tracker = yield* Effect.serviceOption(CurrentTracker)
|
|
54
|
+
if (Option.isSome(tracker)) {
|
|
55
|
+
tracker.value.add(store)
|
|
56
|
+
}
|
|
57
|
+
return store.getSnapshot()
|
|
58
|
+
})
|
|
53
59
|
|
|
54
60
|
const readInputs = <Inputs extends InputRecord>(
|
|
55
61
|
inputs: Inputs,
|
|
@@ -57,32 +63,41 @@ const readInputs = <Inputs extends InputRecord>(
|
|
|
57
63
|
Fn.unsafeCoerce(
|
|
58
64
|
Effect.gen(function* () {
|
|
59
65
|
const pairs = yield* Effect.forEach(Object.values(inputs), (source) =>
|
|
60
|
-
Effect.map(readInput(source), (snapshot)
|
|
66
|
+
Effect.map(readInput(source), (snapshot): readonly [string, unknown] => [
|
|
67
|
+
source.name,
|
|
68
|
+
snapshot,
|
|
69
|
+
]),
|
|
61
70
|
)
|
|
62
71
|
return Record.fromEntries(pairs)
|
|
63
72
|
}),
|
|
64
73
|
)
|
|
65
74
|
|
|
66
|
-
const limitationsFor = <A>(
|
|
67
|
-
limitations: Limitations,
|
|
68
|
-
path: string,
|
|
69
|
-
): FieldLimitations<A> =>
|
|
75
|
+
const limitationsFor = <A>(limitations: Limitations, path: string): FieldLimitations<A> =>
|
|
70
76
|
Fn.unsafeCoerce(Option.getOrElse(Option.fromNullable(limitations[path]), () => ({})))
|
|
71
77
|
|
|
72
|
-
const trigger = <P>(
|
|
73
|
-
eventTrigger
|
|
78
|
+
const trigger = <P>(
|
|
79
|
+
eventTrigger: Effect.Effect<Trigger<P>, never, Bus>,
|
|
80
|
+
): Effect.Effect<Trigger<P>, never, Bus> => eventTrigger
|
|
74
81
|
|
|
75
|
-
// oxlint-disable-next-line reform-rules/prefer-effect-fn -- public generic view
|
|
76
|
-
export const view = <
|
|
77
|
-
|
|
78
|
-
|
|
82
|
+
// oxlint-disable-next-line reform-rules/prefer-effect-fn -- public generic view preserves the exact form state/config/input requirements verbatim
|
|
83
|
+
export const view = <
|
|
84
|
+
N extends string,
|
|
85
|
+
Schema extends S.Schema.AnyNoContext,
|
|
86
|
+
FormInputs extends InputRecord,
|
|
87
|
+
>(
|
|
88
|
+
form: FormClass<N, Schema, FormInputs>,
|
|
89
|
+
): Effect.Effect<
|
|
90
|
+
FormView<ValuesOfSchema<Schema>, InputsObject<FormInputs>>,
|
|
91
|
+
never,
|
|
92
|
+
| State.StateStore<`${N}/state`, FormState<ValuesOfSchema<Schema>>>
|
|
93
|
+
| RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<FormInputs>>
|
|
94
|
+
| Bus
|
|
95
|
+
| InputStores<FormInputs>
|
|
96
|
+
> =>
|
|
79
97
|
Effect.gen(function* () {
|
|
80
98
|
const state = yield* form.state
|
|
81
99
|
const runtimeConfig = yield* form.config
|
|
82
|
-
|
|
83
|
-
const inputs: Inputs<F> = Fn.unsafeCoerce(
|
|
84
|
-
yield* readInputs(Fn.unsafeCoerce<typeof form.manifest.inputs, InputRecord>(form.manifest.inputs)),
|
|
85
|
-
)
|
|
100
|
+
const inputs = yield* readInputs(form.manifest.inputs)
|
|
86
101
|
const limitations = Option.getOrElse(
|
|
87
102
|
Option.fromNullable(runtimeConfig.limit?.({ values: state.values, inputs })),
|
|
88
103
|
() => ({}),
|
|
@@ -96,7 +111,7 @@ export const view = <F extends AnyForm>(
|
|
|
96
111
|
const swap = yield* trigger(form.events.swap.trigger)
|
|
97
112
|
const runtime = yield* Effect.runtime<Bus>()
|
|
98
113
|
|
|
99
|
-
const schema
|
|
114
|
+
const schema = form.manifest.schema
|
|
100
115
|
const decodeEither = S.decodeUnknownEither(schema, { errors: 'all' })(state.values)
|
|
101
116
|
const canSubmit = Either.isRight(decodeEither) && Record.keys(state.errors).length === 0
|
|
102
117
|
|
|
@@ -108,15 +123,21 @@ export const view = <F extends AnyForm>(
|
|
|
108
123
|
}
|
|
109
124
|
const decoded = S.decodeUnknownEither(schema, { errors: 'all' })(state.values)
|
|
110
125
|
if (Either.isLeft(decoded)) {
|
|
111
|
-
yield* Event.dispatch(form.events.validationFinished, {
|
|
112
|
-
|
|
126
|
+
yield* Event.dispatch(form.events.validationFinished, {
|
|
127
|
+
errors: routeParseError(decoded.left),
|
|
128
|
+
})
|
|
129
|
+
return Option.none<DecodedOfSchema<Schema>>()
|
|
113
130
|
}
|
|
114
|
-
const custom = yield* runtimeConfig.validate({
|
|
131
|
+
const custom = yield* runtimeConfig.validate({
|
|
132
|
+
values: state.values,
|
|
133
|
+
decoded: decoded.right,
|
|
134
|
+
inputs,
|
|
135
|
+
})
|
|
115
136
|
const errors = errorsToRecord(custom)
|
|
116
137
|
yield* Event.dispatch(form.events.validationFinished, { errors })
|
|
117
138
|
return Record.keys(errors).length === 0
|
|
118
|
-
? Option.some(
|
|
119
|
-
: Option.none<
|
|
139
|
+
? Option.some(decoded.right)
|
|
140
|
+
: Option.none<DecodedOfSchema<Schema>>()
|
|
120
141
|
})
|
|
121
142
|
|
|
122
143
|
const submit = () => {
|
|
@@ -128,7 +149,9 @@ export const view = <F extends AnyForm>(
|
|
|
128
149
|
}
|
|
129
150
|
yield* runtimeConfig.submit({ values: state.values, decoded: decoded.value, inputs })
|
|
130
151
|
yield* Event.dispatch(form.events.submitSucceeded, { values: state.values })
|
|
131
|
-
}).pipe(
|
|
152
|
+
}).pipe(
|
|
153
|
+
Effect.catchAllCause((cause) => Effect.logError('reform form submit failed', cause)),
|
|
154
|
+
),
|
|
132
155
|
)
|
|
133
156
|
}
|
|
134
157
|
|
|
@@ -136,16 +159,21 @@ export const view = <F extends AnyForm>(
|
|
|
136
159
|
Runtime.runFork(runtime)(runValidation(false))
|
|
137
160
|
}
|
|
138
161
|
|
|
139
|
-
const field = <P extends FieldPath<
|
|
140
|
-
|
|
162
|
+
const field = <P extends FieldPath<ValuesOfSchema<Schema>>>(
|
|
163
|
+
path: P,
|
|
164
|
+
): FieldBinding<PathValue<ValuesOfSchema<Schema>, P>> => {
|
|
165
|
+
const fieldValue: PathValue<ValuesOfSchema<Schema>, P> = Fn.unsafeCoerce(
|
|
166
|
+
getNestedValue(state.values, path),
|
|
167
|
+
)
|
|
141
168
|
return {
|
|
142
169
|
path,
|
|
143
170
|
value: fieldValue,
|
|
144
171
|
set: (next) => {
|
|
145
172
|
if (typeof next === 'function') {
|
|
146
|
-
const updater = Fn.unsafeCoerce<
|
|
147
|
-
next,
|
|
148
|
-
|
|
173
|
+
const updater = Fn.unsafeCoerce<
|
|
174
|
+
typeof next,
|
|
175
|
+
(prev: PathValue<ValuesOfSchema<Schema>, P>) => PathValue<ValuesOfSchema<Schema>, P>
|
|
176
|
+
>(next)
|
|
149
177
|
setField({ path, value: updater(fieldValue) })
|
|
150
178
|
return
|
|
151
179
|
}
|
|
@@ -156,12 +184,14 @@ export const view = <F extends AnyForm>(
|
|
|
156
184
|
dirty: isPathOrParentDirty(state.dirtyPaths, path),
|
|
157
185
|
touched: state.touched[path] === true,
|
|
158
186
|
validating: false,
|
|
159
|
-
limitations: limitationsFor<PathValue<
|
|
187
|
+
limitations: limitationsFor<PathValue<ValuesOfSchema<Schema>, P>>(limitations, path),
|
|
160
188
|
}
|
|
161
189
|
}
|
|
162
190
|
|
|
163
|
-
const array = <P extends ArrayPath<
|
|
164
|
-
|
|
191
|
+
const array = <P extends ArrayPath<ValuesOfSchema<Schema>>>(
|
|
192
|
+
path: P,
|
|
193
|
+
): ArrayBinding<ArrayItem<ValuesOfSchema<Schema>, P>> => {
|
|
194
|
+
const arrayValues: ReadonlyArray<ArrayItem<ValuesOfSchema<Schema>, P>> = Fn.unsafeCoerce(
|
|
165
195
|
currentArray({ source: state.values, path }),
|
|
166
196
|
)
|
|
167
197
|
const keys = state.arrayKeys[path] ?? arrayValues.map((_, index) => `${path}-${index}`)
|
|
@@ -178,11 +208,14 @@ export const view = <F extends AnyForm>(
|
|
|
178
208
|
remove: (index) => remove({ path, index }),
|
|
179
209
|
move: (from, to) => move({ path, from, to }),
|
|
180
210
|
swap: (first, second) => swap({ path, a: first, b: second }),
|
|
181
|
-
limitations: limitationsFor<ReadonlyArray<ArrayItem<
|
|
211
|
+
limitations: limitationsFor<ReadonlyArray<ArrayItem<ValuesOfSchema<Schema>, P>>>(
|
|
212
|
+
limitations,
|
|
213
|
+
path,
|
|
214
|
+
),
|
|
182
215
|
}
|
|
183
216
|
}
|
|
184
217
|
|
|
185
|
-
const formView:
|
|
218
|
+
const formView: FormView<ValuesOfSchema<Schema>, InputsObject<FormInputs>> = Fn.unsafeCoerce({
|
|
186
219
|
values: state.values,
|
|
187
220
|
inputs,
|
|
188
221
|
errors: state.errors,
|
|
@@ -200,3 +233,4 @@ export const view = <F extends AnyForm>(
|
|
|
200
233
|
})
|
|
201
234
|
return formView
|
|
202
235
|
})
|
|
236
|
+
|
package/src/index.ts
CHANGED
|
@@ -16,13 +16,7 @@ export type {
|
|
|
16
16
|
Values,
|
|
17
17
|
View,
|
|
18
18
|
} from './form'
|
|
19
|
-
export {
|
|
20
|
-
arrayKeysFor as unsafeArrayKeysFor,
|
|
21
|
-
error,
|
|
22
|
-
live,
|
|
23
|
-
make,
|
|
24
|
-
view,
|
|
25
|
-
} from './form'
|
|
19
|
+
export { arrayKeysFor as unsafeArrayKeysFor, error, live, make, view } from './form'
|
|
26
20
|
export type {
|
|
27
21
|
ArrayItem,
|
|
28
22
|
ArrayPath,
|
package/src/path.ts
CHANGED
|
@@ -24,52 +24,48 @@ type IsTagged<T> = T extends Tagged ? true : false
|
|
|
24
24
|
export type FieldPath<T> = T extends Primitive
|
|
25
25
|
? never
|
|
26
26
|
: {
|
|
27
|
-
readonly [K in StringKey<T>]:
|
|
28
|
-
|
|
27
|
+
readonly [K in StringKey<T>]: T[K] extends ReadonlyArray<unknown>
|
|
28
|
+
? never
|
|
29
|
+
: IsTagged<T[K]> extends true
|
|
29
30
|
? never
|
|
30
|
-
:
|
|
31
|
-
?
|
|
32
|
-
: T[K] extends
|
|
33
|
-
? K
|
|
34
|
-
:
|
|
35
|
-
? K | `${K}.${FieldPath<T[K]>}`
|
|
36
|
-
: K
|
|
31
|
+
: T[K] extends Primitive
|
|
32
|
+
? K
|
|
33
|
+
: T[K] extends object
|
|
34
|
+
? K | `${K}.${FieldPath<T[K]>}`
|
|
35
|
+
: K
|
|
37
36
|
}[StringKey<T>]
|
|
38
37
|
|
|
39
38
|
export type ArrayPath<T> = T extends Primitive
|
|
40
39
|
? never
|
|
41
40
|
: {
|
|
42
|
-
readonly [K in StringKey<T>]:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
: never
|
|
41
|
+
readonly [K in StringKey<T>]: T[K] extends ReadonlyArray<unknown>
|
|
42
|
+
? K
|
|
43
|
+
: IsTagged<T[K]> extends true
|
|
44
|
+
? never
|
|
45
|
+
: T[K] extends object
|
|
46
|
+
? `${K}.${ArrayPath<T[K]>}`
|
|
47
|
+
: never
|
|
50
48
|
}[StringKey<T>]
|
|
51
49
|
|
|
52
50
|
export type VariantPath<T> = T extends Primitive
|
|
53
51
|
? never
|
|
54
52
|
: {
|
|
55
|
-
readonly [K in StringKey<T>]:
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
: never
|
|
53
|
+
readonly [K in StringKey<T>]: IsTagged<T[K]> extends true
|
|
54
|
+
? K
|
|
55
|
+
: T[K] extends ReadonlyArray<unknown>
|
|
56
|
+
? never
|
|
57
|
+
: T[K] extends object
|
|
58
|
+
? `${K}.${VariantPath<T[K]>}`
|
|
59
|
+
: never
|
|
63
60
|
}[StringKey<T>]
|
|
64
61
|
|
|
65
|
-
export type PathValue<T, P extends string> =
|
|
66
|
-
|
|
67
|
-
? Head
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
: never
|
|
62
|
+
export type PathValue<T, P extends string> = P extends `${infer Head}.${infer Tail}`
|
|
63
|
+
? Head extends keyof T
|
|
64
|
+
? PathValue<T[Head], Tail>
|
|
65
|
+
: never
|
|
66
|
+
: P extends keyof T
|
|
67
|
+
? T[P]
|
|
68
|
+
: never
|
|
73
69
|
|
|
74
70
|
export type ArrayItem<T, P extends string> =
|
|
75
71
|
PathValue<T, P> extends ReadonlyArray<infer Item> ? Item : never
|
|
@@ -196,7 +192,9 @@ export const recalculateDirtyPaths = (
|
|
|
196
192
|
) {
|
|
197
193
|
const currentRecord = asRecord(current)
|
|
198
194
|
const originalRecord = asRecord(original)
|
|
199
|
-
const keys = Array.from(
|
|
195
|
+
const keys = Array.from(
|
|
196
|
+
new Set([...Record.keys(currentRecord), ...Record.keys(originalRecord)]),
|
|
197
|
+
)
|
|
200
198
|
return keys.flatMap((key) =>
|
|
201
199
|
visit({
|
|
202
200
|
current: currentRecord[key],
|