@playfast/reform-forms 1.0.1 → 1.2.0
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 +35 -51
- package/src/form.ts +239 -141
- package/src/index.ts +1 -7
- package/src/path.ts +33 -38
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.0.1",
|
|
5
|
-
"type": "module",
|
|
3
|
+
"version": "1.2.0",
|
|
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,5 +1,5 @@
|
|
|
1
|
-
import { expect,
|
|
2
|
-
import { Effect, Layer,
|
|
1
|
+
import { expect, it } from '@effect/vitest'
|
|
2
|
+
import { Effect, Layer, Option, Schema as S } from 'effect'
|
|
3
3
|
import { Engine } from '@playfast/reform'
|
|
4
4
|
import * as Form from './form'
|
|
5
5
|
|
|
@@ -23,7 +23,9 @@ const CheckoutSchema = S.Struct({
|
|
|
23
23
|
),
|
|
24
24
|
})
|
|
25
25
|
|
|
26
|
-
class CheckoutForm extends Form.make('CheckoutForm', {
|
|
26
|
+
class CheckoutForm extends Form.make('CheckoutForm', {
|
|
27
|
+
schema: CheckoutSchema,
|
|
28
|
+
}) {}
|
|
27
29
|
|
|
28
30
|
const layer = Form.live(CheckoutForm, {
|
|
29
31
|
initial: {
|
|
@@ -35,58 +37,40 @@ const layer = Form.live(CheckoutForm, {
|
|
|
35
37
|
values.email.includes('@') ? undefined : Form.error('email', 'Email must contain @'),
|
|
36
38
|
}).pipe(Layer.provideMerge(Engine))
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
Effect.gen(function* () {
|
|
43
|
-
const view = yield* Form.view(CheckoutForm)
|
|
44
|
-
view.field('email').set('a@example.com')
|
|
40
|
+
it.scopedLive('field bindings write through the form reducer', () =>
|
|
41
|
+
Effect.gen(function* () {
|
|
42
|
+
const view = yield* Form.view(CheckoutForm)
|
|
43
|
+
view.field('email').set('a@example.com')
|
|
45
44
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
45
|
+
const next = yield* waitUntil(
|
|
46
|
+
Form.view(CheckoutForm),
|
|
47
|
+
(v) => v.values.email === 'a@example.com',
|
|
49
48
|
)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
})
|
|
49
|
+
expect(next.field('email').dirty).toBe(true)
|
|
50
|
+
}).pipe(Effect.provide(layer)),
|
|
51
|
+
)
|
|
54
52
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
Effect.gen(function* () {
|
|
60
|
-
const view = yield* Form.view(CheckoutForm)
|
|
61
|
-
view.array('items').append({ name: 'milk', qty: 1 })
|
|
53
|
+
it.scopedLive('array bindings append and remove items', () =>
|
|
54
|
+
Effect.gen(function* () {
|
|
55
|
+
const view = yield* Form.view(CheckoutForm)
|
|
56
|
+
view.array('items').append({ name: 'milk', qty: 1 })
|
|
62
57
|
|
|
63
|
-
|
|
64
|
-
|
|
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-/)
|
|
65
60
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
} finally {
|
|
72
|
-
await runtime.dispose()
|
|
73
|
-
}
|
|
74
|
-
})
|
|
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([])
|
|
64
|
+
}).pipe(Effect.provide(layer)),
|
|
65
|
+
)
|
|
75
66
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
Effect.gen(function* () {
|
|
81
|
-
const view = yield* Form.view(CheckoutForm)
|
|
82
|
-
view.submit()
|
|
67
|
+
it.scopedLive('submit routes custom validation errors to fields', () =>
|
|
68
|
+
Effect.gen(function* () {
|
|
69
|
+
const view = yield* Form.view(CheckoutForm)
|
|
70
|
+
view.submit()
|
|
83
71
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
} finally {
|
|
90
|
-
await runtime.dispose()
|
|
91
|
-
}
|
|
92
|
-
})
|
|
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 @')
|
|
75
|
+
}).pipe(Effect.provide(layer)),
|
|
76
|
+
)
|
package/src/form.ts
CHANGED
|
@@ -9,17 +9,17 @@ import {
|
|
|
9
9
|
Runtime,
|
|
10
10
|
Schema as S,
|
|
11
11
|
} from 'effect'
|
|
12
|
+
import { Event, Reducer, State, type Trigger } from '@playfast/reform'
|
|
12
13
|
import {
|
|
14
|
+
type AnySource,
|
|
13
15
|
Bus,
|
|
14
16
|
CurrentTracker,
|
|
15
|
-
Event,
|
|
16
|
-
Reducer,
|
|
17
17
|
Reducers,
|
|
18
|
-
|
|
18
|
+
type SourceIdentifier,
|
|
19
|
+
type SourceName,
|
|
20
|
+
type SourceValue as ReformSourceValue,
|
|
19
21
|
type Store,
|
|
20
|
-
|
|
21
|
-
} from '@playfast/reform'
|
|
22
|
-
import type { AnySource, Source } from '@playfast/reform'
|
|
22
|
+
} from '@playfast/reform/internal'
|
|
23
23
|
import {
|
|
24
24
|
getNestedValue,
|
|
25
25
|
isPathOrParentDirty,
|
|
@@ -46,9 +46,6 @@ import {
|
|
|
46
46
|
|
|
47
47
|
export { error }
|
|
48
48
|
|
|
49
|
-
// Plain-JSON UI-contract shape (flows into FieldBinding.limitations, serialized
|
|
50
|
-
// over the wire): optional knobs are part of the external contract, so the
|
|
51
|
-
// `ExternalApi` postfix opts this out of the Option-only field rule.
|
|
52
49
|
export interface FieldLimitationsExternalApi<A = unknown> {
|
|
53
50
|
readonly required?: boolean
|
|
54
51
|
readonly disabled?: boolean
|
|
@@ -129,33 +126,25 @@ export interface FormView<Values, Inputs = {}> {
|
|
|
129
126
|
|
|
130
127
|
type InputRecord = Readonly<Record<string, AnySource>>
|
|
131
128
|
|
|
132
|
-
// Extracted structurally (`S['name']`) rather than through a `Source<infer N,
|
|
133
|
-
// unknown>` conditional: `Source`'s value parameter is invariant (`in out A`),
|
|
134
|
-
// so a concrete `StateToken<'x', V>` never matches `Source<_, unknown>` and the
|
|
135
|
-
// conditional would erase every input's key to `never`.
|
|
136
|
-
type SourceName<S extends AnySource> = S['name']
|
|
137
|
-
type SourceValue<S> = S extends Source<string, infer A> ? A : never
|
|
138
|
-
|
|
139
129
|
type InputsObject<Inputs extends InputRecord> = {
|
|
140
|
-
readonly [K in keyof Inputs as SourceName<Inputs[K]>]:
|
|
130
|
+
readonly [K in keyof Inputs as SourceName<Inputs[K]>]: ReformSourceValue<Inputs[K]>
|
|
141
131
|
}
|
|
142
132
|
|
|
143
133
|
type InputStores<Inputs extends InputRecord> = {
|
|
144
|
-
[K in keyof Inputs]: Inputs[K] extends {
|
|
134
|
+
[K in keyof Inputs]: Inputs[K] extends {
|
|
135
|
+
readonly store: Context.Tag<infer Service, infer _Store>
|
|
136
|
+
}
|
|
145
137
|
? Service
|
|
146
138
|
: never
|
|
147
139
|
}[keyof Inputs]
|
|
148
140
|
|
|
149
|
-
type ValuesOfSchema<
|
|
150
|
-
type DecodedOfSchema<
|
|
141
|
+
type ValuesOfSchema<Schema extends S.Schema.AnyNoContext> = S.Schema.Encoded<Schema>
|
|
142
|
+
type DecodedOfSchema<Schema extends S.Schema.AnyNoContext> = S.Schema.Type<Schema>
|
|
151
143
|
|
|
152
144
|
export interface RuntimeConfig<Values, Decoded, Inputs> {
|
|
153
145
|
readonly initial: Values
|
|
154
146
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob (not a serializable shape); Option would break external form definitions
|
|
155
|
-
readonly limit?: (ctx: {
|
|
156
|
-
readonly values: Values
|
|
157
|
-
readonly inputs: Inputs
|
|
158
|
-
}) => Limitations
|
|
147
|
+
readonly limit?: (ctx: { readonly values: Values; readonly inputs: Inputs }) => Limitations
|
|
159
148
|
readonly validate: (ctx: {
|
|
160
149
|
readonly values: Values
|
|
161
150
|
readonly decoded: Decoded
|
|
@@ -171,10 +160,7 @@ export interface RuntimeConfig<Values, Decoded, Inputs> {
|
|
|
171
160
|
export interface FormLiveConfig<Values, Decoded, Inputs, R = never> {
|
|
172
161
|
readonly initial: Values
|
|
173
162
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
|
|
174
|
-
readonly limit?: (ctx: {
|
|
175
|
-
readonly values: Values
|
|
176
|
-
readonly inputs: Inputs
|
|
177
|
-
}) => Limitations
|
|
163
|
+
readonly limit?: (ctx: { readonly values: Values; readonly inputs: Inputs }) => Limitations
|
|
178
164
|
// oxlint-disable-next-line reform-rules/no-optional-fields -- optional Effect-returning config knob; Option would break external form definitions
|
|
179
165
|
readonly validate?: (ctx: {
|
|
180
166
|
readonly values: Values
|
|
@@ -193,9 +179,22 @@ export interface FormLiveConfig<Values, Decoded, Inputs, R = never> {
|
|
|
193
179
|
}) => void | Effect.Effect<unknown, unknown, R>
|
|
194
180
|
}
|
|
195
181
|
|
|
196
|
-
export interface
|
|
182
|
+
export interface FormSchemaReflection {
|
|
183
|
+
readonly ast: S.Schema<unknown, unknown, unknown>['ast']
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface FormManifestReflection<N extends string> {
|
|
197
187
|
readonly kind: 'Form'
|
|
198
188
|
readonly name: N
|
|
189
|
+
readonly schema: FormSchemaReflection
|
|
190
|
+
readonly inputs: InputRecord
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface FormManifest<
|
|
194
|
+
N extends string,
|
|
195
|
+
Schema extends S.Schema.AnyNoContext,
|
|
196
|
+
Inputs extends InputRecord,
|
|
197
|
+
> extends FormManifestReflection<N> {
|
|
199
198
|
readonly schema: Schema
|
|
200
199
|
readonly inputs: Inputs
|
|
201
200
|
}
|
|
@@ -209,17 +208,40 @@ interface FormEvents {
|
|
|
209
208
|
readonly submitSucceeded: Event.EventClass<string, { readonly values: unknown }>
|
|
210
209
|
readonly append: Event.EventClass<string, { readonly path: string; readonly value: unknown }>
|
|
211
210
|
readonly remove: Event.EventClass<string, { readonly path: string; readonly index: number }>
|
|
212
|
-
readonly move: Event.EventClass<
|
|
213
|
-
|
|
211
|
+
readonly move: Event.EventClass<
|
|
212
|
+
string,
|
|
213
|
+
{ readonly path: string; readonly from: number; readonly to: number }
|
|
214
|
+
>
|
|
215
|
+
readonly swap: Event.EventClass<
|
|
216
|
+
string,
|
|
217
|
+
{ readonly path: string; readonly a: number; readonly b: number }
|
|
218
|
+
>
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface FormVisitor<Result> {
|
|
222
|
+
readonly visit: <
|
|
223
|
+
N extends string,
|
|
224
|
+
Schema extends S.Schema.AnyNoContext,
|
|
225
|
+
Inputs extends InputRecord,
|
|
226
|
+
>(
|
|
227
|
+
form: FormClass<N, Schema, Inputs>,
|
|
228
|
+
) => Result
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export interface AnyForm {
|
|
232
|
+
new (): {}
|
|
233
|
+
readonly manifest: FormManifestReflection<string>
|
|
234
|
+
readonly capture: <Result>(visitor: FormVisitor<Result>) => Result
|
|
214
235
|
}
|
|
215
236
|
|
|
216
237
|
export interface FormClass<
|
|
217
238
|
N extends string,
|
|
218
|
-
Schema extends S.Schema.
|
|
239
|
+
Schema extends S.Schema.AnyNoContext,
|
|
219
240
|
Inputs extends InputRecord,
|
|
220
241
|
> {
|
|
221
242
|
new (): {}
|
|
222
243
|
readonly manifest: FormManifest<N, Schema, Inputs>
|
|
244
|
+
readonly capture: <Result>(visitor: FormVisitor<Result>) => Result
|
|
223
245
|
readonly state: State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>>
|
|
224
246
|
readonly config: Context.Tag<
|
|
225
247
|
RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>,
|
|
@@ -232,27 +254,16 @@ export interface FormClass<
|
|
|
232
254
|
>
|
|
233
255
|
}
|
|
234
256
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
// for a concrete form — whose inputs object has real keys — to remain assignable
|
|
238
|
-
// to `AnyForm`. Same Schema-variance escape the schema slot already uses.
|
|
239
|
-
export type AnyForm = FormClass<string, S.Schema.Any, any>
|
|
240
|
-
|
|
241
|
-
export type Values<F extends AnyForm> = F extends FormClass<string, infer Schema, any>
|
|
242
|
-
? ValuesOfSchema<Schema>
|
|
243
|
-
: never
|
|
257
|
+
export type Values<F> =
|
|
258
|
+
F extends FormClass<infer _Name, infer Schema, infer _Inputs> ? ValuesOfSchema<Schema> : never
|
|
244
259
|
|
|
245
|
-
export type Decoded<F
|
|
246
|
-
? DecodedOfSchema<Schema>
|
|
247
|
-
: never
|
|
260
|
+
export type Decoded<F> =
|
|
261
|
+
F extends FormClass<infer _Name, infer Schema, infer _Inputs> ? DecodedOfSchema<Schema> : never
|
|
248
262
|
|
|
249
|
-
export type Inputs<F
|
|
250
|
-
? InputsObject<Inputs>
|
|
251
|
-
: never
|
|
263
|
+
export type Inputs<F> =
|
|
264
|
+
F extends FormClass<infer _Name, infer _Schema, infer Inputs> ? InputsObject<Inputs> : never
|
|
252
265
|
|
|
253
|
-
export type View<F
|
|
254
|
-
|
|
255
|
-
const unknownSchema: S.Schema<any, any> = Fn.unsafeCoerce(S.Unknown)
|
|
266
|
+
export type View<F> = FormView<Values<F>, Inputs<F>>
|
|
256
267
|
|
|
257
268
|
const arrayKeyCounter = { current: 0 }
|
|
258
269
|
const makeArrayKey = (): string => `form-item-${arrayKeyCounter.current++}`
|
|
@@ -262,12 +273,15 @@ export const arrayKeysFor = (source: unknown, path = ''): Record<string, Readonl
|
|
|
262
273
|
const visit = (node: KeyVisitNode): void => {
|
|
263
274
|
if (Array.isArray(node.current)) {
|
|
264
275
|
out[node.path] = node.current.map(() => makeArrayKey())
|
|
265
|
-
node.current.forEach((element, index) =>
|
|
276
|
+
node.current.forEach((element, index) =>
|
|
277
|
+
visit({ current: element, path: `${node.path}[${index}]` }),
|
|
278
|
+
)
|
|
266
279
|
return
|
|
267
280
|
}
|
|
268
281
|
if (node.current !== null && typeof node.current === 'object') {
|
|
269
|
-
Record.toEntries(Fn.unsafeCoerce<unknown, Record<string, unknown>>(node.current)).forEach(
|
|
270
|
-
|
|
282
|
+
Record.toEntries(Fn.unsafeCoerce<unknown, Record<string, unknown>>(node.current)).forEach(
|
|
283
|
+
([key, child]) =>
|
|
284
|
+
visit({ current: child, path: node.path.length === 0 ? key : `${node.path}.${key}` }),
|
|
271
285
|
)
|
|
272
286
|
}
|
|
273
287
|
}
|
|
@@ -287,8 +301,10 @@ const initialState = <Values>(initial: Values): FormState<Values> => ({
|
|
|
287
301
|
arrayKeys: arrayKeysFor(initial),
|
|
288
302
|
})
|
|
289
303
|
|
|
290
|
-
const markTouched = (
|
|
291
|
-
|
|
304
|
+
const markTouched = (
|
|
305
|
+
touched: Readonly<Record<string, boolean>>,
|
|
306
|
+
path: string,
|
|
307
|
+
): Readonly<Record<string, boolean>> => ({ ...touched, [path]: true })
|
|
292
308
|
|
|
293
309
|
interface ArrayLookup {
|
|
294
310
|
readonly source: unknown
|
|
@@ -339,7 +355,8 @@ const updateKeys = (
|
|
|
339
355
|
path: string,
|
|
340
356
|
transform: (keys: ReadonlyArray<string>) => ReadonlyArray<string>,
|
|
341
357
|
): Readonly<Record<string, ReadonlyArray<string>>> => {
|
|
342
|
-
const existing =
|
|
358
|
+
const existing =
|
|
359
|
+
state.arrayKeys[path] ?? currentArray({ source: state.values, path }).map(() => makeArrayKey())
|
|
343
360
|
return { ...state.arrayKeys, [path]: transform(existing) }
|
|
344
361
|
}
|
|
345
362
|
|
|
@@ -351,7 +368,10 @@ const appendAtPath = <Values>(input: SetPathInput<Values>): FormState<Values> =>
|
|
|
351
368
|
const nextValues = setNestedValue(input.state.values, input.path, [...elements, input.value])
|
|
352
369
|
return {
|
|
353
370
|
...withValues(input.state, nextValues),
|
|
354
|
-
arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => [
|
|
371
|
+
arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) => [
|
|
372
|
+
...keys,
|
|
373
|
+
makeArrayKey(),
|
|
374
|
+
]),
|
|
355
375
|
}
|
|
356
376
|
}
|
|
357
377
|
|
|
@@ -382,7 +402,9 @@ const moveAtPath = <Values>(input: MovePathInput<Values>): FormState<Values> =>
|
|
|
382
402
|
const nextValues = setNestedValue(input.state.values, input.path, next)
|
|
383
403
|
return {
|
|
384
404
|
...withValues(input.state, nextValues),
|
|
385
|
-
arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) =>
|
|
405
|
+
arrayKeys: updateKeys(Fn.unsafeCoerce(input.state), input.path, (keys) =>
|
|
406
|
+
moveAt(keys, input.from, input.to),
|
|
407
|
+
),
|
|
386
408
|
}
|
|
387
409
|
}
|
|
388
410
|
|
|
@@ -397,7 +419,11 @@ const swapAtPath = <Values>(input: SwapPathInput<Values>): FormState<Values> =>
|
|
|
397
419
|
) {
|
|
398
420
|
return input.state
|
|
399
421
|
}
|
|
400
|
-
const swapped = replaceAt(
|
|
422
|
+
const swapped = replaceAt(
|
|
423
|
+
replaceAt(elements, input.first, elements[input.second]),
|
|
424
|
+
input.second,
|
|
425
|
+
elements[input.first],
|
|
426
|
+
)
|
|
401
427
|
const nextValues = setNestedValue(input.state.values, input.path, swapped)
|
|
402
428
|
return {
|
|
403
429
|
...withValues(input.state, nextValues),
|
|
@@ -411,7 +437,7 @@ const swapAtPath = <Values>(input: SwapPathInput<Values>): FormState<Values> =>
|
|
|
411
437
|
}
|
|
412
438
|
}
|
|
413
439
|
|
|
414
|
-
interface MakeConfig<Schema extends S.Schema.
|
|
440
|
+
interface MakeConfig<Schema extends S.Schema.AnyNoContext, Inputs extends InputRecord> {
|
|
415
441
|
readonly schema: Schema
|
|
416
442
|
// 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
|
|
417
443
|
readonly inputs?: Inputs
|
|
@@ -419,30 +445,51 @@ interface MakeConfig<Schema extends S.Schema.Any, Inputs extends InputRecord> {
|
|
|
419
445
|
|
|
420
446
|
export const make = <
|
|
421
447
|
const N extends string,
|
|
422
|
-
Schema extends S.Schema.
|
|
448
|
+
Schema extends S.Schema.AnyNoContext,
|
|
423
449
|
const Inputs extends InputRecord = {},
|
|
424
450
|
>(
|
|
425
451
|
name: N,
|
|
426
452
|
config: MakeConfig<Schema, Inputs>,
|
|
427
453
|
): FormClass<N, Schema, Inputs> => {
|
|
428
|
-
const
|
|
429
|
-
|
|
454
|
+
const valuesSchema = S.encodedSchema(config.schema)
|
|
455
|
+
const stringRecord = S.Record({ key: S.String, value: S.String })
|
|
456
|
+
const stateSchema = S.Struct({
|
|
457
|
+
values: valuesSchema,
|
|
458
|
+
initialValues: valuesSchema,
|
|
459
|
+
touched: S.Record({ key: S.String, value: S.Boolean }),
|
|
460
|
+
errors: stringRecord,
|
|
461
|
+
dirtyPaths: S.Array(S.String),
|
|
462
|
+
submitCount: S.Number,
|
|
463
|
+
validationCount: S.Number,
|
|
464
|
+
lastSubmittedValues: S.OptionFromSelf(valuesSchema),
|
|
465
|
+
arrayKeys: S.Record({ key: S.String, value: S.Array(S.String) }),
|
|
466
|
+
})
|
|
467
|
+
const state: State.StateClass<`${N}/state`, FormState<ValuesOfSchema<Schema>>> = State.make(
|
|
468
|
+
`${name}/state`,
|
|
469
|
+
stateSchema,
|
|
470
|
+
{ title: `${name} form state` },
|
|
430
471
|
)
|
|
431
472
|
const events: FormEvents = {
|
|
432
473
|
set: Event.make(`${name}/SetField`, S.Struct({ path: S.String, value: S.Unknown })),
|
|
433
474
|
blur: Event.make(`${name}/BlurField`, S.Struct({ path: S.String })),
|
|
434
475
|
reset: Event.make(`${name}/Reset`, S.Struct({})),
|
|
435
|
-
validationFinished: Event.make(
|
|
436
|
-
|
|
437
|
-
|
|
476
|
+
validationFinished: Event.make(
|
|
477
|
+
`${name}/ValidationFinished`,
|
|
478
|
+
S.Struct({
|
|
479
|
+
errors: S.Record({ key: S.String, value: S.String }),
|
|
480
|
+
}),
|
|
481
|
+
),
|
|
438
482
|
submitAttempted: Event.make(`${name}/SubmitAttempted`, S.Struct({})),
|
|
439
483
|
submitSucceeded: Event.make(`${name}/SubmitSucceeded`, S.Struct({ values: S.Unknown })),
|
|
440
484
|
append: Event.make(`${name}/AppendItem`, S.Struct({ path: S.String, value: S.Unknown })),
|
|
441
485
|
remove: Event.make(`${name}/RemoveItem`, S.Struct({ path: S.String, index: S.Number })),
|
|
442
|
-
move: Event.make(
|
|
486
|
+
move: Event.make(
|
|
487
|
+
`${name}/MoveItem`,
|
|
488
|
+
S.Struct({ path: S.String, from: S.Number, to: S.Number }),
|
|
489
|
+
),
|
|
443
490
|
swap: Event.make(`${name}/SwapItems`, S.Struct({ path: S.String, a: S.Number, b: S.Number })),
|
|
444
491
|
}
|
|
445
|
-
const reducer = Reducer.make(`${name}/FormReducer`, {
|
|
492
|
+
const reducer: FormClass<N, Schema, Inputs>['reducer'] = Reducer.make(`${name}/FormReducer`, {
|
|
446
493
|
states: [state],
|
|
447
494
|
events: Object.values(events),
|
|
448
495
|
})
|
|
@@ -450,10 +497,12 @@ export const make = <
|
|
|
450
497
|
RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>,
|
|
451
498
|
RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<Inputs>>
|
|
452
499
|
>(`reform/form/${name}/config`)
|
|
453
|
-
const inputs: Inputs = Fn.unsafeCoerce(
|
|
500
|
+
const inputs: Inputs = Fn.unsafeCoerce(
|
|
501
|
+
Option.getOrElse(Option.fromNullable(config.inputs), () => ({})),
|
|
502
|
+
)
|
|
454
503
|
class FormImpl {
|
|
455
|
-
static readonly manifest = {
|
|
456
|
-
kind: 'Form'
|
|
504
|
+
static readonly manifest: FormManifest<N, Schema, Inputs> = {
|
|
505
|
+
kind: 'Form',
|
|
457
506
|
name,
|
|
458
507
|
schema: config.schema,
|
|
459
508
|
inputs,
|
|
@@ -462,33 +511,50 @@ export const make = <
|
|
|
462
511
|
static readonly config = runtimeConfig
|
|
463
512
|
static readonly events = events
|
|
464
513
|
static readonly reducer = reducer
|
|
514
|
+
static capture<Result>(visitor: FormVisitor<Result>): Result {
|
|
515
|
+
return visitor.visit(FormImpl)
|
|
516
|
+
}
|
|
465
517
|
}
|
|
466
|
-
return
|
|
518
|
+
return FormImpl
|
|
467
519
|
}
|
|
468
520
|
|
|
469
521
|
export const live = <
|
|
470
|
-
|
|
522
|
+
N extends string,
|
|
523
|
+
Schema extends S.Schema.AnyNoContext,
|
|
524
|
+
FormInputs extends InputRecord,
|
|
471
525
|
R = never,
|
|
472
526
|
>(
|
|
473
|
-
form:
|
|
474
|
-
config: FormLiveConfig<
|
|
527
|
+
form: FormClass<N, Schema, FormInputs>,
|
|
528
|
+
config: FormLiveConfig<
|
|
529
|
+
ValuesOfSchema<Schema>,
|
|
530
|
+
DecodedOfSchema<Schema>,
|
|
531
|
+
InputsObject<FormInputs>,
|
|
532
|
+
R
|
|
533
|
+
>,
|
|
475
534
|
): Layer.Layer<
|
|
476
|
-
|
|
535
|
+
| State.StateStore<`${N}/state`, FormState<ValuesOfSchema<Schema>>>
|
|
536
|
+
| RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<FormInputs>>,
|
|
477
537
|
never,
|
|
478
538
|
Bus | Reducers | R
|
|
479
539
|
> => {
|
|
480
540
|
const configTag: Context.Tag<
|
|
481
|
-
RuntimeConfig<
|
|
482
|
-
RuntimeConfig<
|
|
483
|
-
> =
|
|
541
|
+
RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<FormInputs>>,
|
|
542
|
+
RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<FormInputs>>
|
|
543
|
+
> = form.config
|
|
484
544
|
const configLayer = Layer.effect(
|
|
485
545
|
configTag,
|
|
486
546
|
Effect.map(Effect.context<R | Bus>(), (context) => {
|
|
487
|
-
const runtimeConfig: RuntimeConfig<
|
|
547
|
+
const runtimeConfig: RuntimeConfig<
|
|
548
|
+
ValuesOfSchema<Schema>,
|
|
549
|
+
DecodedOfSchema<Schema>,
|
|
550
|
+
InputsObject<FormInputs>
|
|
551
|
+
> = {
|
|
488
552
|
initial: config.initial,
|
|
489
553
|
validate: (ctx) => {
|
|
490
554
|
const validationOutput = config.validate?.(ctx)
|
|
491
|
-
const effect = Effect.isEffect(validationOutput)
|
|
555
|
+
const effect = Effect.isEffect(validationOutput)
|
|
556
|
+
? validationOutput
|
|
557
|
+
: Effect.succeed(validationOutput)
|
|
492
558
|
return Fn.unsafeCoerce(
|
|
493
559
|
Effect.map(Effect.provide(effect, context), (rawErrors) => normalizeErrors(rawErrors)),
|
|
494
560
|
)
|
|
@@ -499,9 +565,7 @@ export const live = <
|
|
|
499
565
|
return Fn.unsafeCoerce(Effect.asVoid(Effect.provide(effect, context)))
|
|
500
566
|
},
|
|
501
567
|
}
|
|
502
|
-
return config.limit === undefined
|
|
503
|
-
? runtimeConfig
|
|
504
|
-
: { ...runtimeConfig, limit: config.limit }
|
|
568
|
+
return config.limit === undefined ? runtimeConfig : { ...runtimeConfig, limit: config.limit }
|
|
505
569
|
}),
|
|
506
570
|
)
|
|
507
571
|
|
|
@@ -517,12 +581,19 @@ export const live = <
|
|
|
517
581
|
|
|
518
582
|
const reducerLayer = Reducer.live(
|
|
519
583
|
form.reducer,
|
|
520
|
-
(
|
|
584
|
+
(
|
|
585
|
+
state: FormState<ValuesOfSchema<Schema>>,
|
|
586
|
+
event: InternalEvent,
|
|
587
|
+
): FormState<ValuesOfSchema<Schema>> => {
|
|
521
588
|
if (event._tag === form.events.set.tag) {
|
|
522
|
-
return 'path' in event && 'value' in event
|
|
589
|
+
return 'path' in event && 'value' in event
|
|
590
|
+
? setAtPath({ state, path: event.path, value: event.value })
|
|
591
|
+
: state
|
|
523
592
|
}
|
|
524
593
|
if (event._tag === form.events.blur.tag) {
|
|
525
|
-
return 'path' in event
|
|
594
|
+
return 'path' in event
|
|
595
|
+
? { ...state, touched: markTouched(state.touched, event.path) }
|
|
596
|
+
: state
|
|
526
597
|
}
|
|
527
598
|
if (event._tag === form.events.reset.tag) {
|
|
528
599
|
return initialState(state.initialValues)
|
|
@@ -536,9 +607,15 @@ export const live = <
|
|
|
536
607
|
return { ...state, submitCount: state.submitCount + 1 }
|
|
537
608
|
}
|
|
538
609
|
if (event._tag === form.events.submitSucceeded.tag) {
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
610
|
+
if (!('values' in event)) {
|
|
611
|
+
return state
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
...state,
|
|
615
|
+
lastSubmittedValues: Option.some(
|
|
616
|
+
Fn.unsafeCoerce<unknown, ValuesOfSchema<Schema>>(event.values),
|
|
617
|
+
),
|
|
618
|
+
}
|
|
542
619
|
}
|
|
543
620
|
if (event._tag === form.events.append.tag) {
|
|
544
621
|
return 'path' in event && 'value' in event
|
|
@@ -564,26 +641,23 @@ export const live = <
|
|
|
564
641
|
},
|
|
565
642
|
)
|
|
566
643
|
|
|
567
|
-
return
|
|
568
|
-
Layer.
|
|
569
|
-
Layer.provideMerge(State.live(form.state, initialState(config.initial))),
|
|
570
|
-
),
|
|
644
|
+
return Layer.mergeAll(configLayer, reducerLayer).pipe(
|
|
645
|
+
Layer.provideMerge(State.live(form.state, initialState(config.initial))),
|
|
571
646
|
)
|
|
572
647
|
}
|
|
573
648
|
|
|
574
|
-
const readInput = <
|
|
575
|
-
source:
|
|
576
|
-
): Effect.
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
const store = yield* source.store
|
|
580
|
-
const tracker = yield* Effect.serviceOption(CurrentTracker)
|
|
581
|
-
if (Option.isSome(tracker)) {
|
|
582
|
-
tracker.value.add(store)
|
|
583
|
-
}
|
|
584
|
-
return store.getSnapshot()
|
|
585
|
-
}),
|
|
649
|
+
const readInput = Effect.fn('forms.readInput')(function* <Input extends AnySource>(
|
|
650
|
+
source: Input,
|
|
651
|
+
): Effect.fn.Return<ReformSourceValue<Input>, never, SourceIdentifier<Input>> {
|
|
652
|
+
const store = yield* Context.GenericTag<SourceIdentifier<Input>, Store<ReformSourceValue<Input>>>(
|
|
653
|
+
source.store.key,
|
|
586
654
|
)
|
|
655
|
+
const tracker = yield* Effect.serviceOption(CurrentTracker)
|
|
656
|
+
if (Option.isSome(tracker)) {
|
|
657
|
+
tracker.value.add(store)
|
|
658
|
+
}
|
|
659
|
+
return store.getSnapshot()
|
|
660
|
+
})
|
|
587
661
|
|
|
588
662
|
const readInputs = <Inputs extends InputRecord>(
|
|
589
663
|
inputs: Inputs,
|
|
@@ -591,35 +665,41 @@ const readInputs = <Inputs extends InputRecord>(
|
|
|
591
665
|
Fn.unsafeCoerce(
|
|
592
666
|
Effect.gen(function* () {
|
|
593
667
|
const pairs = yield* Effect.forEach(Object.values(inputs), (source) =>
|
|
594
|
-
Effect.map(readInput(source), (snapshot)
|
|
668
|
+
Effect.map(readInput(source), (snapshot): readonly [string, unknown] => [
|
|
669
|
+
source.name,
|
|
670
|
+
snapshot,
|
|
671
|
+
]),
|
|
595
672
|
)
|
|
596
673
|
return Record.fromEntries(pairs)
|
|
597
674
|
}),
|
|
598
675
|
)
|
|
599
676
|
|
|
600
|
-
const limitationsFor = <A>(
|
|
601
|
-
limitations: Limitations,
|
|
602
|
-
path: string,
|
|
603
|
-
): FieldLimitations<A> =>
|
|
677
|
+
const limitationsFor = <A>(limitations: Limitations, path: string): FieldLimitations<A> =>
|
|
604
678
|
Fn.unsafeCoerce(Option.getOrElse(Option.fromNullable(limitations[path]), () => ({})))
|
|
605
679
|
|
|
606
|
-
const trigger = <P>(
|
|
607
|
-
eventTrigger
|
|
680
|
+
const trigger = <P>(
|
|
681
|
+
eventTrigger: Effect.Effect<Trigger<P>, never, Bus>,
|
|
682
|
+
): Effect.Effect<Trigger<P>, never, Bus> => eventTrigger
|
|
608
683
|
|
|
609
|
-
// oxlint-disable-next-line reform-rules/prefer-effect-fn -- public generic view
|
|
610
|
-
export const view = <
|
|
611
|
-
|
|
612
|
-
|
|
684
|
+
// oxlint-disable-next-line reform-rules/prefer-effect-fn -- public generic view preserves the exact form state/config/input requirements verbatim
|
|
685
|
+
export const view = <
|
|
686
|
+
N extends string,
|
|
687
|
+
Schema extends S.Schema.AnyNoContext,
|
|
688
|
+
FormInputs extends InputRecord,
|
|
689
|
+
>(
|
|
690
|
+
form: FormClass<N, Schema, FormInputs>,
|
|
691
|
+
): Effect.Effect<
|
|
692
|
+
FormView<ValuesOfSchema<Schema>, InputsObject<FormInputs>>,
|
|
693
|
+
never,
|
|
694
|
+
| State.StateStore<`${N}/state`, FormState<ValuesOfSchema<Schema>>>
|
|
695
|
+
| RuntimeConfig<ValuesOfSchema<Schema>, DecodedOfSchema<Schema>, InputsObject<FormInputs>>
|
|
696
|
+
| Bus
|
|
697
|
+
| InputStores<FormInputs>
|
|
698
|
+
> =>
|
|
613
699
|
Effect.gen(function* () {
|
|
614
700
|
const state = yield* form.state
|
|
615
701
|
const runtimeConfig = yield* form.config
|
|
616
|
-
|
|
617
|
-
// under the `AnyForm` constraint `form.manifest.inputs` is the wildcard
|
|
618
|
-
// `any`, which would dissolve the generator's R to `unknown`; restate the
|
|
619
|
-
// call at the concrete `F`'s types so R keeps the declared input stores.
|
|
620
|
-
const inputs: Inputs<F> = Fn.unsafeCoerce(
|
|
621
|
-
yield* readInputs(Fn.unsafeCoerce<typeof form.manifest.inputs, InputRecord>(form.manifest.inputs)),
|
|
622
|
-
)
|
|
702
|
+
const inputs = yield* readInputs(form.manifest.inputs)
|
|
623
703
|
const limitations = Option.getOrElse(
|
|
624
704
|
Option.fromNullable(runtimeConfig.limit?.({ values: state.values, inputs })),
|
|
625
705
|
() => ({}),
|
|
@@ -633,7 +713,7 @@ export const view = <F extends AnyForm>(
|
|
|
633
713
|
const swap = yield* trigger(form.events.swap.trigger)
|
|
634
714
|
const runtime = yield* Effect.runtime<Bus>()
|
|
635
715
|
|
|
636
|
-
const schema
|
|
716
|
+
const schema = form.manifest.schema
|
|
637
717
|
const decodeEither = S.decodeUnknownEither(schema, { errors: 'all' })(state.values)
|
|
638
718
|
const canSubmit = Either.isRight(decodeEither) && Record.keys(state.errors).length === 0
|
|
639
719
|
|
|
@@ -645,15 +725,21 @@ export const view = <F extends AnyForm>(
|
|
|
645
725
|
}
|
|
646
726
|
const decoded = S.decodeUnknownEither(schema, { errors: 'all' })(state.values)
|
|
647
727
|
if (Either.isLeft(decoded)) {
|
|
648
|
-
yield* Event.dispatch(form.events.validationFinished, {
|
|
649
|
-
|
|
728
|
+
yield* Event.dispatch(form.events.validationFinished, {
|
|
729
|
+
errors: routeParseError(decoded.left),
|
|
730
|
+
})
|
|
731
|
+
return Option.none<DecodedOfSchema<Schema>>()
|
|
650
732
|
}
|
|
651
|
-
const custom = yield* runtimeConfig.validate({
|
|
733
|
+
const custom = yield* runtimeConfig.validate({
|
|
734
|
+
values: state.values,
|
|
735
|
+
decoded: decoded.right,
|
|
736
|
+
inputs,
|
|
737
|
+
})
|
|
652
738
|
const errors = errorsToRecord(custom)
|
|
653
739
|
yield* Event.dispatch(form.events.validationFinished, { errors })
|
|
654
740
|
return Record.keys(errors).length === 0
|
|
655
|
-
? Option.some(
|
|
656
|
-
: Option.none<
|
|
741
|
+
? Option.some(decoded.right)
|
|
742
|
+
: Option.none<DecodedOfSchema<Schema>>()
|
|
657
743
|
})
|
|
658
744
|
|
|
659
745
|
const submit = () => {
|
|
@@ -665,7 +751,9 @@ export const view = <F extends AnyForm>(
|
|
|
665
751
|
}
|
|
666
752
|
yield* runtimeConfig.submit({ values: state.values, decoded: decoded.value, inputs })
|
|
667
753
|
yield* Event.dispatch(form.events.submitSucceeded, { values: state.values })
|
|
668
|
-
}).pipe(
|
|
754
|
+
}).pipe(
|
|
755
|
+
Effect.catchAllCause((cause) => Effect.logError('reform form submit failed', cause)),
|
|
756
|
+
),
|
|
669
757
|
)
|
|
670
758
|
}
|
|
671
759
|
|
|
@@ -673,16 +761,21 @@ export const view = <F extends AnyForm>(
|
|
|
673
761
|
Runtime.runFork(runtime)(runValidation(false))
|
|
674
762
|
}
|
|
675
763
|
|
|
676
|
-
const field = <P extends FieldPath<
|
|
677
|
-
|
|
764
|
+
const field = <P extends FieldPath<ValuesOfSchema<Schema>>>(
|
|
765
|
+
path: P,
|
|
766
|
+
): FieldBinding<PathValue<ValuesOfSchema<Schema>, P>> => {
|
|
767
|
+
const fieldValue: PathValue<ValuesOfSchema<Schema>, P> = Fn.unsafeCoerce(
|
|
768
|
+
getNestedValue(state.values, path),
|
|
769
|
+
)
|
|
678
770
|
return {
|
|
679
771
|
path,
|
|
680
772
|
value: fieldValue,
|
|
681
773
|
set: (next) => {
|
|
682
774
|
if (typeof next === 'function') {
|
|
683
|
-
const updater = Fn.unsafeCoerce<
|
|
684
|
-
next,
|
|
685
|
-
|
|
775
|
+
const updater = Fn.unsafeCoerce<
|
|
776
|
+
typeof next,
|
|
777
|
+
(prev: PathValue<ValuesOfSchema<Schema>, P>) => PathValue<ValuesOfSchema<Schema>, P>
|
|
778
|
+
>(next)
|
|
686
779
|
setField({ path, value: updater(fieldValue) })
|
|
687
780
|
return
|
|
688
781
|
}
|
|
@@ -693,12 +786,14 @@ export const view = <F extends AnyForm>(
|
|
|
693
786
|
dirty: isPathOrParentDirty(state.dirtyPaths, path),
|
|
694
787
|
touched: state.touched[path] === true,
|
|
695
788
|
validating: false,
|
|
696
|
-
limitations: limitationsFor<PathValue<
|
|
789
|
+
limitations: limitationsFor<PathValue<ValuesOfSchema<Schema>, P>>(limitations, path),
|
|
697
790
|
}
|
|
698
791
|
}
|
|
699
792
|
|
|
700
|
-
const array = <P extends ArrayPath<
|
|
701
|
-
|
|
793
|
+
const array = <P extends ArrayPath<ValuesOfSchema<Schema>>>(
|
|
794
|
+
path: P,
|
|
795
|
+
): ArrayBinding<ArrayItem<ValuesOfSchema<Schema>, P>> => {
|
|
796
|
+
const arrayValues: ReadonlyArray<ArrayItem<ValuesOfSchema<Schema>, P>> = Fn.unsafeCoerce(
|
|
702
797
|
currentArray({ source: state.values, path }),
|
|
703
798
|
)
|
|
704
799
|
const keys = state.arrayKeys[path] ?? arrayValues.map((_, index) => `${path}-${index}`)
|
|
@@ -715,11 +810,14 @@ export const view = <F extends AnyForm>(
|
|
|
715
810
|
remove: (index) => remove({ path, index }),
|
|
716
811
|
move: (from, to) => move({ path, from, to }),
|
|
717
812
|
swap: (first, second) => swap({ path, a: first, b: second }),
|
|
718
|
-
limitations: limitationsFor<ReadonlyArray<ArrayItem<
|
|
813
|
+
limitations: limitationsFor<ReadonlyArray<ArrayItem<ValuesOfSchema<Schema>, P>>>(
|
|
814
|
+
limitations,
|
|
815
|
+
path,
|
|
816
|
+
),
|
|
719
817
|
}
|
|
720
818
|
}
|
|
721
819
|
|
|
722
|
-
const formView:
|
|
820
|
+
const formView: FormView<ValuesOfSchema<Schema>, InputsObject<FormInputs>> = Fn.unsafeCoerce({
|
|
723
821
|
values: state.values,
|
|
724
822
|
inputs,
|
|
725
823
|
errors: state.errors,
|
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
|
@@ -5,8 +5,7 @@ import { sort as sortArray } from 'effect/Array'
|
|
|
5
5
|
const BRACKET_NOTATION_REGEX = /\[(\d+)\]/g
|
|
6
6
|
const NO_INDEX = -1
|
|
7
7
|
|
|
8
|
-
//
|
|
9
|
-
// is matched here to stop path recursion at primitive leaves.
|
|
8
|
+
// JSON null is a real form-value leaf — stop path recursion here.
|
|
10
9
|
export type Primitive =
|
|
11
10
|
| string
|
|
12
11
|
| number
|
|
@@ -25,52 +24,48 @@ type IsTagged<T> = T extends Tagged ? true : false
|
|
|
25
24
|
export type FieldPath<T> = T extends Primitive
|
|
26
25
|
? never
|
|
27
26
|
: {
|
|
28
|
-
readonly [K in StringKey<T>]:
|
|
29
|
-
|
|
27
|
+
readonly [K in StringKey<T>]: T[K] extends ReadonlyArray<unknown>
|
|
28
|
+
? never
|
|
29
|
+
: IsTagged<T[K]> extends true
|
|
30
30
|
? never
|
|
31
|
-
:
|
|
32
|
-
?
|
|
33
|
-
: T[K] extends
|
|
34
|
-
? K
|
|
35
|
-
:
|
|
36
|
-
? K | `${K}.${FieldPath<T[K]>}`
|
|
37
|
-
: K
|
|
31
|
+
: T[K] extends Primitive
|
|
32
|
+
? K
|
|
33
|
+
: T[K] extends object
|
|
34
|
+
? K | `${K}.${FieldPath<T[K]>}`
|
|
35
|
+
: K
|
|
38
36
|
}[StringKey<T>]
|
|
39
37
|
|
|
40
38
|
export type ArrayPath<T> = T extends Primitive
|
|
41
39
|
? never
|
|
42
40
|
: {
|
|
43
|
-
readonly [K in StringKey<T>]:
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
: 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
|
|
51
48
|
}[StringKey<T>]
|
|
52
49
|
|
|
53
50
|
export type VariantPath<T> = T extends Primitive
|
|
54
51
|
? never
|
|
55
52
|
: {
|
|
56
|
-
readonly [K in StringKey<T>]:
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
: 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
|
|
64
60
|
}[StringKey<T>]
|
|
65
61
|
|
|
66
|
-
export type PathValue<T, P extends string> =
|
|
67
|
-
|
|
68
|
-
? Head
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
: 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
|
|
74
69
|
|
|
75
70
|
export type ArrayItem<T, P extends string> =
|
|
76
71
|
PathValue<T, P> extends ReadonlyArray<infer Item> ? Item : never
|
|
@@ -83,8 +78,6 @@ export type VariantCase<V, Tag extends string> = Extract<V, { readonly _tag: Tag
|
|
|
83
78
|
|
|
84
79
|
export type VariantBody<V, Tag extends string> = Omit<VariantCase<V, Tag>, '_tag'>
|
|
85
80
|
|
|
86
|
-
// Typed view over an arbitrary object for string-keyed traversal of unknown form
|
|
87
|
-
// values. `unsafeCoerce` is the Effect-blessed identity coercion (no `as`).
|
|
88
81
|
const asRecord = (source: unknown): Record<string, unknown> => Fn.unsafeCoerce(source)
|
|
89
82
|
|
|
90
83
|
export const schemaPathToFieldPath = (path: ReadonlyArray<PropertyKey>): string =>
|
|
@@ -199,7 +192,9 @@ export const recalculateDirtyPaths = (
|
|
|
199
192
|
) {
|
|
200
193
|
const currentRecord = asRecord(current)
|
|
201
194
|
const originalRecord = asRecord(original)
|
|
202
|
-
const keys = Array.from(
|
|
195
|
+
const keys = Array.from(
|
|
196
|
+
new Set([...Record.keys(currentRecord), ...Record.keys(originalRecord)]),
|
|
197
|
+
)
|
|
203
198
|
return keys.flatMap((key) =>
|
|
204
199
|
visit({
|
|
205
200
|
current: currentRecord[key],
|